From 2b097eefb3334762cf73166c36684833cfd1309d Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 13 Dec 2025 21:49:13 +0000 Subject: [PATCH] feat: add comprehensive blogging and team features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../(frontend)/api/team/[slug]/vcard/route.ts | 187 ++++ src/app/(frontend)/api/team/route.ts | 214 +++++ src/blocks/AuthorBioBlock.ts | 206 ++++ src/blocks/OrgChartBlock.ts | 455 +++++++++ src/blocks/RelatedPostsBlock.ts | 263 ++++++ src/blocks/ShareButtonsBlock.ts | 306 ++++++ src/blocks/TableOfContentsBlock.ts | 287 ++++++ src/blocks/TeamFilterBlock.ts | 381 ++++++++ src/blocks/index.ts | 10 + src/collections/Authors.ts | 211 +++++ src/collections/Pages.ts | 16 + src/collections/Posts.ts | 87 +- src/collections/Tags.ts | 81 ++ src/collections/Team.ts | 66 ++ .../20251213_220000_blogging_collections.ts | 735 ++++++++++++++ .../20251213_230000_team_extensions.ts | 451 +++++++++ src/payload-types.ts | 894 +++++++++++++++++- src/payload.config.ts | 10 + 18 files changed, 4858 insertions(+), 2 deletions(-) create mode 100644 src/app/(frontend)/api/team/[slug]/vcard/route.ts create mode 100644 src/app/(frontend)/api/team/route.ts create mode 100644 src/blocks/AuthorBioBlock.ts create mode 100644 src/blocks/OrgChartBlock.ts create mode 100644 src/blocks/RelatedPostsBlock.ts create mode 100644 src/blocks/ShareButtonsBlock.ts create mode 100644 src/blocks/TableOfContentsBlock.ts create mode 100644 src/blocks/TeamFilterBlock.ts create mode 100644 src/collections/Authors.ts create mode 100644 src/collections/Tags.ts create mode 100644 src/migrations/20251213_220000_blogging_collections.ts create mode 100644 src/migrations/20251213_230000_team_extensions.ts diff --git a/src/app/(frontend)/api/team/[slug]/vcard/route.ts b/src/app/(frontend)/api/team/[slug]/vcard/route.ts new file mode 100644 index 0000000..2be75f9 --- /dev/null +++ b/src/app/(frontend)/api/team/[slug]/vcard/route.ts @@ -0,0 +1,187 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * vCard Export API + * + * GET /api/team/[slug]/vcard - Generiert vCard (VCF) für ein Team-Mitglied + * + * Query-Parameter: + * - tenant (required): Tenant ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params + const { searchParams } = new URL(request.url) + + // Required: Tenant + const tenantId = searchParams.get('tenant') + if (!tenantId) { + return NextResponse.json({ error: 'tenant parameter is required' }, { status: 400 }) + } + + const payload = await getPayload({ config }) + + // Find team member + const result = await payload.find({ + collection: 'team', + where: { + tenant: { equals: parseInt(tenantId) }, + slug: { equals: slug }, + isActive: { equals: true }, + }, + depth: 1, + limit: 1, + }) + + if (result.docs.length === 0) { + return NextResponse.json({ error: 'Team member not found' }, { status: 404 }) + } + + const member = result.docs[0] + + // Check if vCard export is allowed + if (member.allowVCard === false) { + return NextResponse.json({ error: 'vCard export not allowed for this member' }, { status: 403 }) + } + + // Generate vCard 3.0 + const vcard = generateVCard(member) + + // Return as downloadable file + const filename = `${member.slug || member.name?.toLowerCase().replace(/\s+/g, '-')}.vcf` + + return new NextResponse(vcard, { + status: 200, + headers: { + 'Content-Type': 'text/vcard; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (error) { + console.error('vCard API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +interface TeamMember { + name?: string + role?: string + department?: string + email?: string + phone?: string + showContactInfo?: boolean + image?: { url?: string } | number + socialLinks?: Array<{ platform?: string; url?: string }> + qualifications?: Array<{ title?: string }> + specializations?: Array<{ title?: string }> + languages?: Array<{ language?: string; level?: string }> + bioShort?: string +} + +function generateVCard(member: TeamMember): string { + const lines: string[] = [] + + // vCard header + lines.push('BEGIN:VCARD') + lines.push('VERSION:3.0') + + // Name + if (member.name) { + const nameParts = member.name.split(' ') + const lastName = nameParts.pop() || '' + const firstName = nameParts.join(' ') + lines.push(`N:${escapeVCard(lastName)};${escapeVCard(firstName)};;;`) + lines.push(`FN:${escapeVCard(member.name)}`) + } + + // Organization & Title + if (member.role) { + lines.push(`TITLE:${escapeVCard(member.role)}`) + } + if (member.department) { + lines.push(`ORG:;${escapeVCard(member.department)}`) + } + + // Contact info (only if allowed) + if (member.showContactInfo) { + if (member.email) { + lines.push(`EMAIL;TYPE=WORK:${member.email}`) + } + if (member.phone) { + lines.push(`TEL;TYPE=WORK:${member.phone}`) + } + } + + // Photo URL (if available) + if (member.image && typeof member.image === 'object' && member.image.url) { + lines.push(`PHOTO;VALUE=URI:${member.image.url}`) + } + + // Social Links as URLs + if (Array.isArray(member.socialLinks)) { + member.socialLinks.forEach((link, index) => { + if (link.url) { + const label = link.platform?.toUpperCase() || `SOCIAL${index + 1}` + lines.push(`URL;TYPE=${label}:${link.url}`) + } + }) + } + + // Note with bio and qualifications + const notes: string[] = [] + if (member.bioShort) { + notes.push(member.bioShort) + } + if (Array.isArray(member.qualifications) && member.qualifications.length > 0) { + const quals = member.qualifications.map((q) => q.title).filter(Boolean).join(', ') + if (quals) { + notes.push(`Qualifikationen: ${quals}`) + } + } + if (Array.isArray(member.specializations) && member.specializations.length > 0) { + const specs = member.specializations.map((s) => s.title).filter(Boolean).join(', ') + if (specs) { + notes.push(`Fachgebiete: ${specs}`) + } + } + if (Array.isArray(member.languages) && member.languages.length > 0) { + const langs = member.languages.map((l) => l.language).filter(Boolean).join(', ') + if (langs) { + notes.push(`Sprachen: ${langs}`) + } + } + if (notes.length > 0) { + lines.push(`NOTE:${escapeVCard(notes.join('\\n'))}`) + } + + // Categories (specializations as tags) + if (Array.isArray(member.specializations) && member.specializations.length > 0) { + const categories = member.specializations.map((s) => s.title).filter(Boolean).join(',') + if (categories) { + lines.push(`CATEGORIES:${escapeVCard(categories)}`) + } + } + + // Production ID and revision + lines.push(`PRODID:-//Payload CMS//Team vCard//DE`) + lines.push(`REV:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z`) + + // vCard footer + lines.push('END:VCARD') + + return lines.join('\r\n') +} + +function escapeVCard(text: string): string { + if (!text) return '' + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') +} diff --git a/src/app/(frontend)/api/team/route.ts b/src/app/(frontend)/api/team/route.ts new file mode 100644 index 0000000..5a7deae --- /dev/null +++ b/src/app/(frontend)/api/team/route.ts @@ -0,0 +1,214 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * Team API + * + * GET /api/team - Liste aller Team-Mitglieder mit Filter und Suche + * + * Query-Parameter: + * - tenant (required): Tenant ID + * - slug: Einzelnes Mitglied nach Slug + * - search: Volltextsuche in Name, Rolle, Abteilung, Bio + * - department: Nach Abteilung filtern + * - level: Nach Hierarchie-Ebene filtern + * - specialization: Nach Fachgebiet filtern + * - language: Nach Sprache filtern + * - featured: Nur hervorgehobene (true/false) + * - limit: Maximale Anzahl (default: 50) + * - page: Seite für Pagination + * - sort: Sortierung (order, name, department, startDate) + * - locale: Sprache (de/en) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + + // Required: Tenant + const tenantId = searchParams.get('tenant') + if (!tenantId) { + return NextResponse.json({ error: 'tenant parameter is required' }, { status: 400 }) + } + + const payload = await getPayload({ config }) + + // Optional parameters + const slug = searchParams.get('slug') + const search = searchParams.get('search') + const department = searchParams.get('department') + const level = searchParams.get('level') + const specialization = searchParams.get('specialization') + const language = searchParams.get('language') + const featured = searchParams.get('featured') + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100) + const page = parseInt(searchParams.get('page') || '1') + const sort = searchParams.get('sort') || 'order' + const locale = (searchParams.get('locale') as 'de' | 'en') || 'de' + + // Build where clause + const where: Record = { + tenant: { equals: parseInt(tenantId) }, + isActive: { equals: true }, + } + + // Single member by slug + if (slug) { + where.slug = { equals: slug } + } + + // Department filter + if (department) { + where.department = { contains: department } + } + + // Hierarchy level filter + if (level) { + where.hierarchyLevel = { equals: level } + } + + // Featured filter + if (featured === 'true') { + where.isFeatured = { equals: true } + } + + // Build sort string + let sortField = 'order' + switch (sort) { + case 'name': + sortField = 'name' + break + case 'department': + sortField = 'department' + break + case 'startDate': + sortField = '-startDate' + break + case '-order': + sortField = '-order' + break + default: + sortField = 'order' + } + + // Query team members + const result = await payload.find({ + collection: 'team', + where, + sort: sortField, + limit, + page, + locale, + depth: 2, // Include image and reportsTo + }) + + let members = result.docs + + // Post-query filters (for array fields) + + // Search filter (case-insensitive) + if (search) { + const searchLower = search.toLowerCase() + members = members.filter((member) => { + const nameMatch = member.name?.toLowerCase().includes(searchLower) + const roleMatch = + typeof member.role === 'string' && member.role.toLowerCase().includes(searchLower) + const deptMatch = + typeof member.department === 'string' && + member.department.toLowerCase().includes(searchLower) + const bioMatch = + typeof member.bioShort === 'string' && + member.bioShort.toLowerCase().includes(searchLower) + + // Search in specializations + const specMatch = + Array.isArray(member.specializations) && + member.specializations.some( + (s) => typeof s.title === 'string' && s.title.toLowerCase().includes(searchLower) + ) + + return nameMatch || roleMatch || deptMatch || bioMatch || specMatch + }) + } + + // Specialization filter + if (specialization) { + const specLower = specialization.toLowerCase() + members = members.filter( + (member) => + Array.isArray(member.specializations) && + member.specializations.some( + (s) => typeof s.title === 'string' && s.title.toLowerCase().includes(specLower) + ) + ) + } + + // Language filter + if (language) { + const langLower = language.toLowerCase() + members = members.filter( + (member) => + Array.isArray(member.languages) && + member.languages.some( + (l) => typeof l.language === 'string' && l.language.toLowerCase().includes(langLower) + ) + ) + } + + // Get unique departments for filter dropdown + const allMembers = await payload.find({ + collection: 'team', + where: { + tenant: { equals: parseInt(tenantId) }, + isActive: { equals: true }, + }, + limit: 1000, + locale, + }) + + const departments = [ + ...new Set(allMembers.docs.map((m) => m.department).filter(Boolean)), + ].sort() as string[] + + const specializations = [ + ...new Set( + allMembers.docs.flatMap((m) => + Array.isArray(m.specializations) ? m.specializations.map((s) => s.title) : [] + ) + ), + ] + .filter(Boolean) + .sort() as string[] + + const languages = [ + ...new Set( + allMembers.docs.flatMap((m) => + Array.isArray(m.languages) ? m.languages.map((l) => l.language) : [] + ) + ), + ] + .filter(Boolean) + .sort() as string[] + + // Single member response + if (slug && members.length === 1) { + return NextResponse.json({ + member: members[0], + filters: { departments, specializations, languages }, + }) + } + + return NextResponse.json({ + members, + totalDocs: result.totalDocs, + totalPages: result.totalPages, + page: result.page, + hasNextPage: result.hasNextPage, + hasPrevPage: result.hasPrevPage, + filters: { departments, specializations, languages }, + }) + } catch (error) { + console.error('Team API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/blocks/AuthorBioBlock.ts b/src/blocks/AuthorBioBlock.ts new file mode 100644 index 0000000..99948cd --- /dev/null +++ b/src/blocks/AuthorBioBlock.ts @@ -0,0 +1,206 @@ +import type { Block } from 'payload' + +/** + * AuthorBioBlock + * + * Zeigt Autoren-Informationen am Ende eines Blog-Posts an. + * Kann automatisch den Artikel-Autor oder manuell ausgewählte Autoren anzeigen. + */ +export const AuthorBioBlock: Block = { + slug: 'author-bio-block', + labels: { + singular: 'Autoren-Bio', + plural: 'Autoren-Bios', + }, + imageURL: '/assets/blocks/author-bio.png', + fields: [ + { + name: 'source', + type: 'select', + defaultValue: 'post', + label: 'Autoren-Quelle', + options: [ + { label: 'Autor des Artikels', value: 'post' }, + { label: 'Manuell auswählen', value: 'manual' }, + ], + admin: { + description: 'Woher sollen die Autoren-Daten kommen?', + }, + }, + { + name: 'authors', + type: 'relationship', + relationTo: 'authors', + hasMany: true, + label: 'Autoren', + admin: { + condition: (_, siblingData) => siblingData?.source === 'manual', + description: 'Wählen Sie die anzuzeigenden Autoren', + }, + }, + { + name: 'showCoAuthors', + type: 'checkbox', + defaultValue: true, + label: 'Co-Autoren anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.source === 'post', + description: 'Auch Co-Autoren des Artikels anzeigen', + }, + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'card', + label: 'Layout', + options: [ + { label: 'Karte', value: 'card' }, + { label: 'Inline', value: 'inline' }, + { label: 'Kompakt', value: 'compact' }, + { label: 'Feature (Groß)', value: 'feature' }, + ], + }, + // Anzuzeigende Elemente + { + name: 'show', + type: 'group', + label: 'Anzeigen', + fields: [ + { + name: 'avatar', + type: 'checkbox', + defaultValue: true, + label: 'Profilbild', + }, + { + name: 'name', + type: 'checkbox', + defaultValue: true, + label: 'Name', + }, + { + name: 'title', + type: 'checkbox', + defaultValue: true, + label: 'Titel/Position', + }, + { + name: 'bio', + type: 'select', + defaultValue: 'short', + label: 'Biografie', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Kurz', value: 'short' }, + { label: 'Vollständig', value: 'full' }, + ], + }, + { + name: 'social', + type: 'checkbox', + defaultValue: true, + label: 'Social Media Links', + }, + { + name: 'email', + type: 'checkbox', + defaultValue: false, + label: 'E-Mail', + }, + { + name: 'website', + type: 'checkbox', + defaultValue: false, + label: 'Website', + }, + { + name: 'postCount', + type: 'checkbox', + defaultValue: false, + label: 'Anzahl Artikel', + }, + ], + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'avatarSize', + type: 'select', + defaultValue: 'md', + label: 'Avatar-Größe', + options: [ + { label: 'Klein (48px)', value: 'sm' }, + { label: 'Mittel (80px)', value: 'md' }, + { label: 'Groß (120px)', value: 'lg' }, + ], + }, + { + name: 'avatarShape', + type: 'select', + defaultValue: 'circle', + label: 'Avatar-Form', + options: [ + { label: 'Rund', value: 'circle' }, + { label: 'Quadratisch', value: 'square' }, + { label: 'Abgerundet', value: 'rounded' }, + ], + }, + { + name: 'bg', + type: 'select', + defaultValue: 'light', + label: 'Hintergrund', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Hell', value: 'light' }, + { label: 'Dunkel', value: 'dark' }, + { label: 'Akzent', value: 'accent' }, + ], + }, + { + name: 'border', + type: 'checkbox', + defaultValue: false, + label: 'Rahmen', + }, + { + name: 'shadow', + type: 'checkbox', + defaultValue: false, + label: 'Schatten', + }, + { + name: 'divider', + type: 'checkbox', + defaultValue: true, + label: 'Trennlinie oben', + admin: { + description: 'Linie zur Trennung vom Artikel-Inhalt', + }, + }, + ], + }, + // Label + { + name: 'label', + type: 'text', + label: 'Überschrift', + localized: true, + admin: { + description: 'z.B. "Über den Autor", "Geschrieben von" (optional)', + }, + }, + // Link zur Autorenseite + { + name: 'linkToProfile', + type: 'checkbox', + defaultValue: true, + label: 'Zur Autorenseite verlinken', + }, + ], +} diff --git a/src/blocks/OrgChartBlock.ts b/src/blocks/OrgChartBlock.ts new file mode 100644 index 0000000..3d66496 --- /dev/null +++ b/src/blocks/OrgChartBlock.ts @@ -0,0 +1,455 @@ +import type { Block } from 'payload' + +/** + * OrgChartBlock (Organigramm) + * + * Hierarchische Darstellung der Team-Struktur. + * Nutzt reportsTo und hierarchyLevel aus der Team Collection. + */ +export const OrgChartBlock: Block = { + slug: 'org-chart-block', + labels: { + singular: 'Organigramm', + plural: 'Organigramme', + }, + imageURL: '/assets/blocks/org-chart.png', + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + defaultValue: 'Unsere Struktur', + }, + { + name: 'subtitle', + type: 'textarea', + label: 'Untertitel', + localized: true, + }, + // Datenquelle + { + name: 'source', + type: 'select', + defaultValue: 'auto', + label: 'Datenquelle', + options: [ + { label: 'Automatisch (aus Hierarchie)', value: 'auto' }, + { label: 'Nach Abteilung', value: 'department' }, + { label: 'Manuell auswählen', value: 'manual' }, + ], + }, + { + name: 'rootMember', + type: 'relationship', + relationTo: 'team', + label: 'Wurzel-Person', + admin: { + condition: (_, siblingData) => siblingData?.source === 'auto', + description: 'Oberstes Element (z.B. Geschäftsführer). Leer = automatisch ermitteln.', + }, + }, + { + name: 'department', + type: 'text', + label: 'Abteilung', + admin: { + condition: (_, siblingData) => siblingData?.source === 'department', + description: 'Zeigt nur diese Abteilung', + }, + }, + { + name: 'selectedMembers', + type: 'relationship', + relationTo: 'team', + hasMany: true, + label: 'Mitglieder auswählen', + admin: { + condition: (_, siblingData) => siblingData?.source === 'manual', + }, + }, + { + name: 'maxDepth', + type: 'number', + defaultValue: 5, + min: 1, + max: 10, + label: 'Maximale Tiefe', + admin: { + description: 'Wie viele Hierarchie-Ebenen anzeigen', + }, + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'tree', + label: 'Layout', + options: [ + { label: 'Baum (vertikal)', value: 'tree' }, + { label: 'Baum (horizontal)', value: 'tree-horizontal' }, + { label: 'Organigramm (klassisch)', value: 'org' }, + { label: 'Radial/Kreis', value: 'radial' }, + { label: 'Ebenen (gestapelt)', value: 'layers' }, + ], + }, + { + name: 'direction', + type: 'select', + defaultValue: 'top', + label: 'Richtung', + options: [ + { label: 'Von oben nach unten', value: 'top' }, + { label: 'Von unten nach oben', value: 'bottom' }, + { label: 'Von links nach rechts', value: 'left' }, + { label: 'Von rechts nach links', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => + siblingData?.layout === 'tree' || siblingData?.layout === 'org', + }, + }, + // Node-Darstellung + { + name: 'node', + type: 'group', + label: 'Knoten-Darstellung', + fields: [ + { + name: 'style', + type: 'select', + defaultValue: 'card', + label: 'Stil', + options: [ + { label: 'Karte', value: 'card' }, + { label: 'Kompakt', value: 'compact' }, + { label: 'Nur Foto', value: 'avatar' }, + { label: 'Nur Text', value: 'text' }, + ], + }, + { + name: 'showImage', + type: 'checkbox', + defaultValue: true, + label: 'Foto anzeigen', + }, + { + name: 'imageSize', + type: 'select', + defaultValue: 'md', + label: 'Foto-Größe', + options: [ + { label: 'Klein (40px)', value: 'sm' }, + { label: 'Mittel (60px)', value: 'md' }, + { label: 'Groß (80px)', value: 'lg' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.showImage, + }, + }, + { + name: 'imageShape', + type: 'select', + defaultValue: 'circle', + label: 'Foto-Form', + options: [ + { label: 'Rund', value: 'circle' }, + { label: 'Abgerundet', value: 'rounded' }, + { label: 'Quadratisch', value: 'square' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.showImage, + }, + }, + { + name: 'showName', + type: 'checkbox', + defaultValue: true, + label: 'Name anzeigen', + }, + { + name: 'showRole', + type: 'checkbox', + defaultValue: true, + label: 'Position anzeigen', + }, + { + name: 'showDepartment', + type: 'checkbox', + defaultValue: false, + label: 'Abteilung anzeigen', + }, + { + name: 'showContact', + type: 'checkbox', + defaultValue: false, + label: 'Kontakt anzeigen', + }, + { + name: 'clickAction', + type: 'select', + defaultValue: 'modal', + label: 'Klick-Aktion', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Detail-Modal', value: 'modal' }, + { label: 'Zur Profilseite', value: 'link' }, + { label: 'Untergeordnete ein-/ausklappen', value: 'expand' }, + ], + }, + { + name: 'profileBasePath', + type: 'text', + label: 'Profil-Basis-Pfad', + defaultValue: '/team', + admin: { + condition: (_, siblingData) => siblingData?.clickAction === 'link', + }, + }, + ], + }, + // Verbindungslinien + { + name: 'connectors', + type: 'group', + label: 'Verbindungslinien', + fields: [ + { + name: 'style', + type: 'select', + defaultValue: 'straight', + label: 'Linien-Stil', + options: [ + { label: 'Gerade', value: 'straight' }, + { label: 'Abgewinkelt', value: 'angular' }, + { label: 'Gebogen', value: 'curved' }, + { label: 'Gestrichelt', value: 'dashed' }, + ], + }, + { + name: 'color', + type: 'select', + defaultValue: 'gray', + label: 'Farbe', + options: [ + { label: 'Grau', value: 'gray' }, + { label: 'Dunkel', value: 'dark' }, + { label: 'Akzent', value: 'accent' }, + { label: 'Gradient', value: 'gradient' }, + ], + }, + { + name: 'thickness', + type: 'select', + defaultValue: '2', + label: 'Dicke', + options: [ + { label: 'Dünn (1px)', value: '1' }, + { label: 'Normal (2px)', value: '2' }, + { label: 'Dick (3px)', value: '3' }, + ], + }, + { + name: 'animated', + type: 'checkbox', + defaultValue: false, + label: 'Animation', + admin: { + description: 'Animierte Linien beim Laden', + }, + }, + ], + }, + // Hierarchie-Ebenen-Styling + { + name: 'levels', + type: 'group', + label: 'Ebenen-Styling', + fields: [ + { + name: 'colorByLevel', + type: 'checkbox', + defaultValue: true, + label: 'Farblich unterscheiden', + admin: { + description: 'Verschiedene Farben pro Hierarchie-Ebene', + }, + }, + { + name: 'sizeByLevel', + type: 'checkbox', + defaultValue: true, + label: 'Größe variieren', + admin: { + description: 'Höhere Ebenen größer darstellen', + }, + }, + { + name: 'collapsible', + type: 'checkbox', + defaultValue: true, + label: 'Ebenen einklappbar', + }, + { + name: 'initiallyExpanded', + type: 'number', + defaultValue: 2, + min: 1, + max: 5, + label: 'Initial ausgeklappt', + admin: { + condition: (_, siblingData) => siblingData?.collapsible, + description: 'Wie viele Ebenen anfangs sichtbar', + }, + }, + ], + }, + // Interaktion + { + name: 'interaction', + type: 'group', + label: 'Interaktion', + fields: [ + { + name: 'zoomable', + type: 'checkbox', + defaultValue: true, + label: 'Zoom erlauben', + }, + { + name: 'pannable', + type: 'checkbox', + defaultValue: true, + label: 'Verschieben erlauben', + }, + { + name: 'minimap', + type: 'checkbox', + defaultValue: false, + label: 'Minimap anzeigen', + admin: { + description: 'Kleine Übersichtskarte bei großen Organigrammen', + }, + }, + { + name: 'search', + type: 'checkbox', + defaultValue: false, + label: 'Suche aktivieren', + }, + { + name: 'highlight', + type: 'checkbox', + defaultValue: true, + label: 'Hover-Hervorhebung', + admin: { + description: 'Bei Hover Pfad zur Wurzel hervorheben', + }, + }, + { + name: 'fullscreen', + type: 'checkbox', + defaultValue: true, + label: 'Vollbild-Modus', + }, + { + name: 'export', + type: 'checkbox', + defaultValue: false, + label: 'Export erlauben', + admin: { + description: 'Als PNG oder PDF exportieren', + }, + }, + ], + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'bg', + type: 'select', + defaultValue: 'light', + label: 'Hintergrund', + options: [ + { label: 'Transparent', value: 'none' }, + { label: 'Hell', value: 'light' }, + { label: 'Weiß', value: 'white' }, + { label: 'Dunkel', value: 'dark' }, + { label: 'Gradient', value: 'gradient' }, + ], + }, + { + name: 'nodeBg', + type: 'select', + defaultValue: 'white', + label: 'Knoten-Hintergrund', + options: [ + { label: 'Weiß', value: 'white' }, + { label: 'Hell', value: 'light' }, + { label: 'Transparent', value: 'transparent' }, + { label: 'Akzent', value: 'accent' }, + ], + }, + { + name: 'nodeShadow', + type: 'checkbox', + defaultValue: true, + label: 'Knoten-Schatten', + }, + { + name: 'nodeBorder', + type: 'checkbox', + defaultValue: false, + label: 'Knoten-Rahmen', + }, + { + name: 'spacing', + type: 'select', + defaultValue: 'md', + label: 'Abstände', + options: [ + { label: 'Kompakt', value: 'sm' }, + { label: 'Normal', value: 'md' }, + { label: 'Großzügig', value: 'lg' }, + ], + }, + { + name: 'minHeight', + type: 'select', + defaultValue: '400', + label: 'Mindesthöhe', + options: [ + { label: 'Auto', value: 'auto' }, + { label: '300px', value: '300' }, + { label: '400px', value: '400' }, + { label: '500px', value: '500' }, + { label: '600px', value: '600' }, + { label: 'Vollbild', value: 'full' }, + ], + }, + ], + }, + // Legend + { + name: 'showLegend', + type: 'checkbox', + defaultValue: false, + label: 'Legende anzeigen', + admin: { + description: 'Erklärt Farben und Symbole', + }, + }, + // Accessibility + { + name: 'a11yLabel', + type: 'text', + label: 'ARIA Label', + localized: true, + defaultValue: 'Organigramm der Unternehmensstruktur', + }, + ], +} diff --git a/src/blocks/RelatedPostsBlock.ts b/src/blocks/RelatedPostsBlock.ts new file mode 100644 index 0000000..71275cb --- /dev/null +++ b/src/blocks/RelatedPostsBlock.ts @@ -0,0 +1,263 @@ +import type { Block } from 'payload' + +/** + * RelatedPostsBlock + * + * Zeigt verwandte Artikel an, basierend auf Kategorien, Tags + * oder manueller Auswahl. + */ +export const RelatedPostsBlock: Block = { + slug: 'related-posts-block', + labels: { + singular: 'Ähnliche Artikel', + plural: 'Ähnliche Artikel', + }, + imageURL: '/assets/blocks/related-posts.png', + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + defaultValue: 'Das könnte Sie auch interessieren', + }, + { + name: 'source', + type: 'select', + defaultValue: 'auto', + label: 'Artikel-Quelle', + options: [ + { label: 'Automatisch (Kategorien/Tags)', value: 'auto' }, + { label: 'Manuell auswählen', value: 'manual' }, + { label: 'Nach Kategorie', value: 'category' }, + { label: 'Nach Tag', value: 'tag' }, + { label: 'Neueste Artikel', value: 'latest' }, + { label: 'Beliebteste Artikel', value: 'popular' }, + { label: 'Vom gleichen Autor', value: 'author' }, + ], + }, + { + name: 'posts', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + label: 'Artikel', + admin: { + condition: (_, siblingData) => siblingData?.source === 'manual', + description: 'Wählen Sie die anzuzeigenden Artikel', + }, + }, + { + name: 'category', + type: 'relationship', + relationTo: 'categories', + label: 'Kategorie', + admin: { + condition: (_, siblingData) => siblingData?.source === 'category', + }, + }, + { + name: 'tag', + type: 'relationship', + relationTo: 'tags', + label: 'Tag', + admin: { + condition: (_, siblingData) => siblingData?.source === 'tag', + }, + }, + { + name: 'limit', + type: 'number', + defaultValue: 3, + min: 1, + max: 12, + label: 'Anzahl Artikel', + }, + { + name: 'excludeCurrent', + type: 'checkbox', + defaultValue: true, + label: 'Aktuellen Artikel ausschließen', + admin: { + description: 'Den Artikel, auf dem dieser Block ist, nicht anzeigen', + }, + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'grid', + label: 'Layout', + options: [ + { label: 'Grid', value: 'grid' }, + { label: 'Liste', value: 'list' }, + { label: 'Slider', value: 'slider' }, + { label: 'Kompakt', value: 'compact' }, + { label: 'Cards', value: 'cards' }, + ], + }, + { + name: 'cols', + type: 'select', + defaultValue: '3', + label: 'Spalten', + options: [ + { label: '2 Spalten', value: '2' }, + { label: '3 Spalten', value: '3' }, + { label: '4 Spalten', value: '4' }, + ], + admin: { + condition: (_, siblingData) => + siblingData?.layout === 'grid' || siblingData?.layout === 'cards', + }, + }, + // Anzuzeigende Elemente + { + name: 'show', + type: 'group', + label: 'Anzeigen', + fields: [ + { + name: 'image', + type: 'checkbox', + defaultValue: true, + label: 'Beitragsbild', + }, + { + name: 'date', + type: 'checkbox', + defaultValue: true, + label: 'Datum', + }, + { + name: 'author', + type: 'checkbox', + defaultValue: false, + label: 'Autor', + }, + { + name: 'category', + type: 'checkbox', + defaultValue: true, + label: 'Kategorie', + }, + { + name: 'excerpt', + type: 'checkbox', + defaultValue: true, + label: 'Kurzfassung', + }, + { + name: 'readingTime', + type: 'checkbox', + defaultValue: false, + label: 'Lesezeit', + }, + { + name: 'tags', + type: 'checkbox', + defaultValue: false, + label: 'Tags', + }, + ], + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'imgRatio', + type: 'select', + defaultValue: '16-9', + label: 'Bild-Verhältnis', + options: [ + { label: 'Quadratisch (1:1)', value: 'square' }, + { label: 'Querformat (4:3)', value: '4-3' }, + { label: 'Breit (16:9)', value: '16-9' }, + { label: 'Hochformat (3:4)', value: '3-4' }, + ], + }, + { + name: 'rounded', + type: 'select', + defaultValue: 'md', + label: 'Ecken', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Klein', value: 'sm' }, + { label: 'Mittel', value: 'md' }, + { label: 'Groß', value: 'lg' }, + ], + }, + { + name: 'shadow', + type: 'checkbox', + defaultValue: false, + label: 'Schatten', + }, + { + name: 'hover', + type: 'select', + defaultValue: 'lift', + label: 'Hover-Effekt', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Anheben', value: 'lift' }, + { label: 'Zoom', value: 'zoom' }, + { label: 'Overlay', value: 'overlay' }, + ], + }, + { + name: 'bg', + type: 'select', + defaultValue: 'none', + label: 'Hintergrund', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Hell', value: 'light' }, + { label: 'Dunkel', value: 'dark' }, + ], + }, + { + name: 'gap', + type: 'select', + defaultValue: '24', + label: 'Abstand', + options: [ + { label: 'Klein (16px)', value: '16' }, + { label: 'Normal (24px)', value: '24' }, + { label: 'Groß (32px)', value: '32' }, + ], + }, + ], + }, + // CTA + { + name: 'showAllLink', + type: 'checkbox', + defaultValue: false, + label: '"Alle anzeigen" Link', + }, + { + name: 'allLinkText', + type: 'text', + label: 'Link-Text', + localized: true, + defaultValue: 'Alle Artikel anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.showAllLink, + }, + }, + { + name: 'allLinkUrl', + type: 'text', + label: 'Link-URL', + admin: { + condition: (_, siblingData) => siblingData?.showAllLink, + description: 'z.B. /blog oder /news', + }, + }, + ], +} diff --git a/src/blocks/ShareButtonsBlock.ts b/src/blocks/ShareButtonsBlock.ts new file mode 100644 index 0000000..25028aa --- /dev/null +++ b/src/blocks/ShareButtonsBlock.ts @@ -0,0 +1,306 @@ +import type { Block } from 'payload' + +/** + * ShareButtonsBlock + * + * Social Sharing Buttons für Blog-Posts und andere Inhalte. + * Unterstützt verschiedene Plattformen und Layouts. + */ +export const ShareButtonsBlock: Block = { + slug: 'share-buttons-block', + labels: { + singular: 'Teilen-Buttons', + plural: 'Teilen-Buttons', + }, + imageURL: '/assets/blocks/share-buttons.png', + fields: [ + { + name: 'label', + type: 'text', + label: 'Label', + localized: true, + admin: { + description: 'z.B. "Teilen:", "Artikel teilen" (optional)', + }, + }, + // Plattformen + { + name: 'platforms', + type: 'group', + label: 'Plattformen', + fields: [ + { + name: 'facebook', + type: 'checkbox', + defaultValue: true, + label: 'Facebook', + }, + { + name: 'twitter', + type: 'checkbox', + defaultValue: true, + label: 'Twitter/X', + }, + { + name: 'linkedin', + type: 'checkbox', + defaultValue: true, + label: 'LinkedIn', + }, + { + name: 'xing', + type: 'checkbox', + defaultValue: false, + label: 'Xing', + }, + { + name: 'whatsapp', + type: 'checkbox', + defaultValue: true, + label: 'WhatsApp', + }, + { + name: 'telegram', + type: 'checkbox', + defaultValue: false, + label: 'Telegram', + }, + { + name: 'email', + type: 'checkbox', + defaultValue: true, + label: 'E-Mail', + }, + { + name: 'copy', + type: 'checkbox', + defaultValue: true, + label: 'Link kopieren', + }, + { + name: 'print', + type: 'checkbox', + defaultValue: false, + label: 'Drucken', + }, + { + name: 'pinterest', + type: 'checkbox', + defaultValue: false, + label: 'Pinterest', + }, + { + name: 'reddit', + type: 'checkbox', + defaultValue: false, + label: 'Reddit', + }, + ], + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'horizontal', + label: 'Layout', + options: [ + { label: 'Horizontal', value: 'horizontal' }, + { label: 'Vertikal', value: 'vertical' }, + { label: 'Floating (Seitenrand)', value: 'floating' }, + { label: 'Sticky (unten)', value: 'sticky' }, + ], + }, + { + name: 'align', + type: 'select', + defaultValue: 'left', + label: 'Ausrichtung', + options: [ + { label: 'Links', value: 'left' }, + { label: 'Mitte', value: 'center' }, + { label: 'Rechts', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => + siblingData?.layout === 'horizontal' || siblingData?.layout === 'vertical', + }, + }, + { + name: 'floatSide', + type: 'select', + defaultValue: 'left', + label: 'Seite', + options: [ + { label: 'Links', value: 'left' }, + { label: 'Rechts', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.layout === 'floating', + }, + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'variant', + type: 'select', + defaultValue: 'filled', + label: 'Button-Stil', + options: [ + { label: 'Gefüllt', value: 'filled' }, + { label: 'Outline', value: 'outline' }, + { label: 'Nur Icon', value: 'icon' }, + { label: 'Minimal', value: 'minimal' }, + ], + }, + { + name: 'size', + type: 'select', + defaultValue: 'md', + label: 'Größe', + options: [ + { label: 'Klein', value: 'sm' }, + { label: 'Mittel', value: 'md' }, + { label: 'Groß', value: 'lg' }, + ], + }, + { + name: 'shape', + type: 'select', + defaultValue: 'rounded', + label: 'Form', + options: [ + { label: 'Eckig', value: 'square' }, + { label: 'Abgerundet', value: 'rounded' }, + { label: 'Rund', value: 'circle' }, + { label: 'Pill', value: 'pill' }, + ], + }, + { + name: 'colorScheme', + type: 'select', + defaultValue: 'brand', + label: 'Farbschema', + options: [ + { label: 'Plattform-Farben', value: 'brand' }, + { label: 'Einheitlich (Grau)', value: 'gray' }, + { label: 'Einheitlich (Dunkel)', value: 'dark' }, + { label: 'Einheitlich (Hell)', value: 'light' }, + { label: 'Theme-Akzent', value: 'accent' }, + ], + }, + { + name: 'gap', + type: 'select', + defaultValue: '8', + label: 'Abstand', + options: [ + { label: 'Kein Abstand', value: '0' }, + { label: 'Klein (4px)', value: '4' }, + { label: 'Normal (8px)', value: '8' }, + { label: 'Groß (12px)', value: '12' }, + ], + }, + { + name: 'showLabel', + type: 'checkbox', + defaultValue: false, + label: 'Plattform-Namen anzeigen', + }, + { + name: 'showCount', + type: 'checkbox', + defaultValue: false, + label: 'Share-Zähler anzeigen', + admin: { + description: 'Zeigt Anzahl der Shares (sofern verfügbar)', + }, + }, + ], + }, + // Verhalten + { + name: 'behavior', + type: 'group', + label: 'Verhalten', + fields: [ + { + name: 'openInPopup', + type: 'checkbox', + defaultValue: true, + label: 'In Popup öffnen', + admin: { + description: 'Share-Dialoge in kleinem Popup statt neuem Tab', + }, + }, + { + name: 'useNativeShare', + type: 'checkbox', + defaultValue: true, + label: 'Native Share API nutzen', + admin: { + description: 'Auf mobilen Geräten den System-Share-Dialog verwenden', + }, + }, + { + name: 'copyFeedback', + type: 'text', + label: 'Kopiert-Feedback', + localized: true, + defaultValue: 'Link kopiert!', + admin: { + description: 'Text der angezeigt wird nach dem Kopieren', + }, + }, + ], + }, + // Share-Inhalt + { + name: 'content', + type: 'group', + label: 'Share-Inhalt', + admin: { + description: 'Überschreibt automatische Werte (optional)', + }, + fields: [ + { + name: 'customTitle', + type: 'text', + label: 'Titel', + localized: true, + admin: { + description: 'Leer lassen für automatischen Seitentitel', + }, + }, + { + name: 'customDescription', + type: 'textarea', + label: 'Beschreibung', + localized: true, + admin: { + description: 'Leer lassen für automatische Meta-Description', + }, + }, + { + name: 'hashtags', + type: 'text', + label: 'Hashtags', + admin: { + description: 'Komma-getrennt, ohne # (z.B. "blog,news,tech")', + }, + }, + { + name: 'via', + type: 'text', + label: 'Twitter Via', + admin: { + description: 'Twitter-Handle ohne @ (z.B. "c2sgmbh")', + }, + }, + ], + }, + ], +} diff --git a/src/blocks/TableOfContentsBlock.ts b/src/blocks/TableOfContentsBlock.ts new file mode 100644 index 0000000..7136767 --- /dev/null +++ b/src/blocks/TableOfContentsBlock.ts @@ -0,0 +1,287 @@ +import type { Block } from 'payload' + +/** + * TableOfContentsBlock + * + * Automatisches Inhaltsverzeichnis für lange Blog-Posts. + * Extrahiert Überschriften aus dem Content und erstellt Navigation. + */ +export const TableOfContentsBlock: Block = { + slug: 'toc-block', + labels: { + singular: 'Inhaltsverzeichnis', + plural: 'Inhaltsverzeichnisse', + }, + imageURL: '/assets/blocks/table-of-contents.png', + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + defaultValue: 'Inhaltsverzeichnis', + }, + // Überschriften-Level + { + name: 'levels', + type: 'group', + label: 'Überschriften-Level', + admin: { + description: 'Welche Überschriften-Ebenen einschließen?', + }, + fields: [ + { + name: 'h2', + type: 'checkbox', + defaultValue: true, + label: 'H2', + }, + { + name: 'h3', + type: 'checkbox', + defaultValue: true, + label: 'H3', + }, + { + name: 'h4', + type: 'checkbox', + defaultValue: false, + label: 'H4', + }, + { + name: 'h5', + type: 'checkbox', + defaultValue: false, + label: 'H5', + }, + { + name: 'h6', + type: 'checkbox', + defaultValue: false, + label: 'H6', + }, + ], + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'list', + label: 'Layout', + options: [ + { label: 'Liste', value: 'list' }, + { label: 'Nummeriert', value: 'numbered' }, + { label: 'Kompakt (Inline)', value: 'inline' }, + { label: 'Sidebar (Sticky)', value: 'sidebar' }, + { label: 'Dropdown/Akkordeon', value: 'dropdown' }, + ], + }, + { + name: 'sidebarPos', + type: 'select', + defaultValue: 'right', + label: 'Sidebar-Position', + options: [ + { label: 'Links', value: 'left' }, + { label: 'Rechts', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.layout === 'sidebar', + }, + }, + // Verhalten + { + name: 'behavior', + type: 'group', + label: 'Verhalten', + fields: [ + { + name: 'smoothScroll', + type: 'checkbox', + defaultValue: true, + label: 'Sanftes Scrollen', + }, + { + name: 'highlightActive', + type: 'checkbox', + defaultValue: true, + label: 'Aktiven Abschnitt markieren', + admin: { + description: 'Markiert den aktuell sichtbaren Abschnitt', + }, + }, + { + name: 'scrollOffset', + type: 'number', + defaultValue: 80, + label: 'Scroll-Offset (px)', + admin: { + description: 'Abstand zum oberen Rand nach dem Scrollen (für Fixed Headers)', + }, + }, + { + name: 'collapsible', + type: 'checkbox', + defaultValue: false, + label: 'Einklappbar', + admin: { + description: 'User kann das Inhaltsverzeichnis ein-/ausklappen', + }, + }, + { + name: 'startCollapsed', + type: 'checkbox', + defaultValue: false, + label: 'Anfangs eingeklappt', + admin: { + condition: (_, siblingData) => siblingData?.collapsible, + }, + }, + { + name: 'showProgress', + type: 'checkbox', + defaultValue: false, + label: 'Lesefortschritt anzeigen', + admin: { + description: 'Fortschrittsbalken oder Prozent-Anzeige', + }, + }, + { + name: 'progressStyle', + type: 'select', + defaultValue: 'bar', + label: 'Fortschritts-Stil', + options: [ + { label: 'Balken', value: 'bar' }, + { label: 'Prozent', value: 'percent' }, + { label: 'Kreis', value: 'circle' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.showProgress, + }, + }, + ], + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'bg', + type: 'select', + defaultValue: 'light', + label: 'Hintergrund', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Hell', value: 'light' }, + { label: 'Dunkel', value: 'dark' }, + { label: 'Akzent', value: 'accent' }, + ], + }, + { + name: 'border', + type: 'checkbox', + defaultValue: true, + label: 'Rahmen', + }, + { + name: 'borderSide', + type: 'select', + defaultValue: 'left', + label: 'Rahmen-Seite', + options: [ + { label: 'Alle', value: 'all' }, + { label: 'Links', value: 'left' }, + { label: 'Oben', value: 'top' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.border, + }, + }, + { + name: 'rounded', + type: 'select', + defaultValue: 'md', + label: 'Ecken', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Klein', value: 'sm' }, + { label: 'Mittel', value: 'md' }, + { label: 'Groß', value: 'lg' }, + ], + }, + { + name: 'shadow', + type: 'checkbox', + defaultValue: false, + label: 'Schatten', + }, + { + name: 'indent', + type: 'checkbox', + defaultValue: true, + label: 'Sub-Überschriften einrücken', + }, + { + name: 'showIcon', + type: 'checkbox', + defaultValue: false, + label: 'Icons anzeigen', + admin: { + description: 'Link-Symbol neben Einträgen', + }, + }, + { + name: 'fontSize', + type: 'select', + defaultValue: 'sm', + label: 'Schriftgröße', + options: [ + { label: 'Klein', value: 'sm' }, + { label: 'Normal', value: 'base' }, + { label: 'Groß', value: 'lg' }, + ], + }, + { + name: 'lineHeight', + type: 'select', + defaultValue: 'relaxed', + label: 'Zeilenhöhe', + options: [ + { label: 'Eng', value: 'tight' }, + { label: 'Normal', value: 'normal' }, + { label: 'Locker', value: 'relaxed' }, + ], + }, + ], + }, + // Limits + { + name: 'minItems', + type: 'number', + defaultValue: 3, + label: 'Mindestanzahl', + admin: { + description: 'Inhaltsverzeichnis nur anzeigen, wenn mindestens X Einträge', + }, + }, + { + name: 'maxItems', + type: 'number', + label: 'Maximalanzahl', + admin: { + description: 'Maximale Anzahl angezeigter Einträge (0 = unbegrenzt)', + }, + }, + // Accessibility + { + name: 'a11yLabel', + type: 'text', + label: 'ARIA Label', + localized: true, + defaultValue: 'Inhaltsverzeichnis', + }, + ], +} diff --git a/src/blocks/TeamFilterBlock.ts b/src/blocks/TeamFilterBlock.ts new file mode 100644 index 0000000..c5bc71f --- /dev/null +++ b/src/blocks/TeamFilterBlock.ts @@ -0,0 +1,381 @@ +import type { Block } from 'payload' + +/** + * TeamFilterBlock + * + * Interaktive Team-Anzeige mit Filter- und Suchfunktion. + * Ermöglicht Filterung nach Abteilung, Fachgebiet, Sprache. + */ +export const TeamFilterBlock: Block = { + slug: 'team-filter-block', + labels: { + singular: 'Team mit Filter', + plural: 'Team mit Filter', + }, + imageURL: '/assets/blocks/team-filter.png', + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + defaultValue: 'Unser Team', + }, + { + name: 'subtitle', + type: 'textarea', + label: 'Untertitel', + localized: true, + }, + // Filter-Optionen + { + name: 'filters', + type: 'group', + label: 'Filter-Optionen', + fields: [ + { + name: 'showSearch', + type: 'checkbox', + defaultValue: true, + label: 'Suchfeld anzeigen', + }, + { + name: 'searchPlaceholder', + type: 'text', + label: 'Such-Platzhalter', + localized: true, + defaultValue: 'Team durchsuchen...', + admin: { + condition: (_, siblingData) => siblingData?.showSearch, + }, + }, + { + name: 'showDepartment', + type: 'checkbox', + defaultValue: true, + label: 'Abteilungs-Filter', + }, + { + name: 'showSpecialization', + type: 'checkbox', + defaultValue: false, + label: 'Fachgebiets-Filter', + }, + { + name: 'showLanguage', + type: 'checkbox', + defaultValue: false, + label: 'Sprach-Filter', + }, + { + name: 'showHierarchy', + type: 'checkbox', + defaultValue: false, + label: 'Hierarchie-Filter', + }, + { + name: 'filterLayout', + type: 'select', + defaultValue: 'horizontal', + label: 'Filter-Layout', + options: [ + { label: 'Horizontal (nebeneinander)', value: 'horizontal' }, + { label: 'Vertikal (Sidebar)', value: 'sidebar' }, + { label: 'Dropdown-Menü', value: 'dropdown' }, + { label: 'Tabs', value: 'tabs' }, + ], + }, + { + name: 'filterStyle', + type: 'select', + defaultValue: 'buttons', + label: 'Filter-Stil', + options: [ + { label: 'Buttons/Chips', value: 'buttons' }, + { label: 'Dropdown', value: 'select' }, + { label: 'Checkboxen', value: 'checkbox' }, + ], + admin: { + condition: (_, siblingData) => + siblingData?.filterLayout !== 'tabs' && siblingData?.filterLayout !== 'dropdown', + }, + }, + { + name: 'showResultCount', + type: 'checkbox', + defaultValue: true, + label: 'Ergebnis-Anzahl anzeigen', + }, + { + name: 'showResetButton', + type: 'checkbox', + defaultValue: true, + label: 'Filter-Reset-Button', + }, + ], + }, + // Anzeige-Optionen + { + name: 'display', + type: 'group', + label: 'Anzeige', + fields: [ + { + name: 'layout', + type: 'select', + defaultValue: 'grid', + label: 'Layout', + options: [ + { label: 'Grid', value: 'grid' }, + { label: 'Liste', value: 'list' }, + { label: 'Kompakt', value: 'compact' }, + ], + }, + { + name: 'columns', + type: 'select', + defaultValue: '3', + label: 'Spalten', + options: [ + { label: '2 Spalten', value: '2' }, + { label: '3 Spalten', value: '3' }, + { label: '4 Spalten', value: '4' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.layout === 'grid', + }, + }, + { + name: 'initialLimit', + type: 'number', + defaultValue: 12, + min: 4, + max: 50, + label: 'Initiale Anzahl', + }, + { + name: 'loadMore', + type: 'select', + defaultValue: 'button', + label: 'Mehr laden', + options: [ + { label: '"Mehr laden" Button', value: 'button' }, + { label: 'Infinite Scroll', value: 'infinite' }, + { label: 'Pagination', value: 'pagination' }, + { label: 'Alle anzeigen', value: 'all' }, + ], + }, + { + name: 'loadMoreText', + type: 'text', + label: 'Button-Text', + localized: true, + defaultValue: 'Mehr anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.loadMore === 'button', + }, + }, + ], + }, + // Member-Card Optionen + { + name: 'card', + type: 'group', + label: 'Mitglieder-Karte', + fields: [ + { + name: 'showImage', + type: 'checkbox', + defaultValue: true, + label: 'Foto anzeigen', + }, + { + name: 'imageStyle', + type: 'select', + defaultValue: 'circle', + label: 'Foto-Stil', + options: [ + { label: 'Rund', value: 'circle' }, + { label: 'Abgerundet', value: 'rounded' }, + { label: 'Quadratisch', value: 'square' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.showImage, + }, + }, + { + name: 'showRole', + type: 'checkbox', + defaultValue: true, + label: 'Position anzeigen', + }, + { + name: 'showDepartment', + type: 'checkbox', + defaultValue: true, + label: 'Abteilung anzeigen', + }, + { + name: 'showBio', + type: 'checkbox', + defaultValue: false, + label: 'Kurzbiografie anzeigen', + }, + { + name: 'showContact', + type: 'checkbox', + defaultValue: false, + label: 'Kontaktdaten anzeigen', + }, + { + name: 'showSocial', + type: 'checkbox', + defaultValue: true, + label: 'Social Links anzeigen', + }, + { + name: 'showSpecializations', + type: 'checkbox', + defaultValue: false, + label: 'Fachgebiete als Tags', + }, + { + name: 'showLanguages', + type: 'checkbox', + defaultValue: false, + label: 'Sprachen anzeigen', + }, + { + name: 'showVCard', + type: 'checkbox', + defaultValue: false, + label: 'vCard-Download anzeigen', + }, + { + name: 'linkToProfile', + type: 'checkbox', + defaultValue: true, + label: 'Zur Profilseite verlinken', + }, + { + name: 'profileBasePath', + type: 'text', + label: 'Profil-Basis-Pfad', + defaultValue: '/team', + admin: { + condition: (_, siblingData) => siblingData?.linkToProfile, + description: 'z.B. "/team" ergibt "/team/max-mustermann"', + }, + }, + { + name: 'enableModal', + type: 'checkbox', + defaultValue: false, + label: 'Detail-Modal aktivieren', + admin: { + description: 'Klick öffnet Modal statt Profilseite', + }, + }, + ], + }, + // Styling + { + name: 'style', + type: 'group', + label: 'Darstellung', + fields: [ + { + name: 'bg', + type: 'select', + defaultValue: 'none', + label: 'Hintergrund', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Hell', value: 'light' }, + { label: 'Dunkel', value: 'dark' }, + ], + }, + { + name: 'cardBg', + type: 'select', + defaultValue: 'white', + label: 'Karten-Hintergrund', + options: [ + { label: 'Weiß', value: 'white' }, + { label: 'Transparent', value: 'transparent' }, + { label: 'Hell', value: 'light' }, + ], + }, + { + name: 'cardShadow', + type: 'checkbox', + defaultValue: true, + label: 'Karten-Schatten', + }, + { + name: 'cardHover', + type: 'select', + defaultValue: 'lift', + label: 'Hover-Effekt', + options: [ + { label: 'Keiner', value: 'none' }, + { label: 'Anheben', value: 'lift' }, + { label: 'Schatten verstärken', value: 'shadow' }, + { label: 'Rand hervorheben', value: 'border' }, + ], + }, + { + name: 'gap', + type: 'select', + defaultValue: '24', + label: 'Abstand', + options: [ + { label: 'Klein (16px)', value: '16' }, + { label: 'Normal (24px)', value: '24' }, + { label: 'Groß (32px)', value: '32' }, + ], + }, + { + name: 'animation', + type: 'select', + defaultValue: 'fade', + label: 'Filter-Animation', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Fade', value: 'fade' }, + { label: 'Slide', value: 'slide' }, + { label: 'Scale', value: 'scale' }, + ], + }, + ], + }, + // Empty State + { + name: 'emptyState', + type: 'group', + label: 'Keine Ergebnisse', + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + defaultValue: 'Keine Mitarbeiter gefunden', + }, + { + name: 'message', + type: 'textarea', + label: 'Nachricht', + localized: true, + defaultValue: 'Versuchen Sie andere Filterkriterien.', + }, + { + name: 'showResetButton', + type: 'checkbox', + defaultValue: true, + label: 'Filter-Reset anzeigen', + }, + ], + }, + ], +} diff --git a/src/blocks/index.ts b/src/blocks/index.ts index 9e3017a..b6977ae 100644 --- a/src/blocks/index.ts +++ b/src/blocks/index.ts @@ -19,3 +19,13 @@ export { ProcessStepsBlock } from './ProcessStepsBlock' export { FAQBlock } from './FAQBlock' export { TeamBlock } from './TeamBlock' export { ServicesBlock } from './ServicesBlock' + +// Blogging Blocks +export { AuthorBioBlock } from './AuthorBioBlock' +export { RelatedPostsBlock } from './RelatedPostsBlock' +export { ShareButtonsBlock } from './ShareButtonsBlock' +export { TableOfContentsBlock } from './TableOfContentsBlock' + +// Team Blocks +export { TeamFilterBlock } from './TeamFilterBlock' +export { OrgChartBlock } from './OrgChartBlock' diff --git a/src/collections/Authors.ts b/src/collections/Authors.ts new file mode 100644 index 0000000..516de3d --- /dev/null +++ b/src/collections/Authors.ts @@ -0,0 +1,211 @@ +import type { CollectionConfig } from 'payload' + +/** + * Authors Collection + * + * Blog-Autoren mit Bio, Bild und Social Links. + * Kann optional mit Team-Mitgliedern verknüpft werden. + */ +export const Authors: CollectionConfig = { + slug: 'authors', + labels: { + singular: 'Autor', + plural: 'Autoren', + }, + admin: { + useAsTitle: 'name', + group: 'Content', + defaultColumns: ['name', 'slug', 'postCount', 'isActive', 'updatedAt'], + description: 'Blog-Autoren und Gastautoren', + }, + access: { + read: () => true, + create: ({ req }) => !!req.user, + update: ({ req }) => !!req.user, + delete: ({ req }) => !!req.user, + }, + fields: [ + // Basis-Informationen + { + name: 'name', + type: 'text', + required: true, + label: 'Name', + admin: { + description: 'Anzeigename des Autors', + }, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + admin: { + description: 'URL-freundlicher Identifier (z.B. "max-mustermann")', + }, + }, + { + name: 'avatar', + type: 'upload', + relationTo: 'media', + label: 'Profilbild', + admin: { + description: 'Avatar/Profilbild (empfohlen: quadratisch, min. 200x200px)', + }, + }, + { + name: 'bio', + type: 'richText', + label: 'Biografie', + localized: true, + admin: { + description: 'Ausführliche Biografie für Autorenseite', + }, + }, + { + name: 'bioShort', + type: 'textarea', + label: 'Kurzbiografie', + localized: true, + admin: { + description: 'Ein bis zwei Sätze für Anzeige unter Artikeln', + }, + }, + { + name: 'title', + type: 'text', + label: 'Titel/Position', + localized: true, + admin: { + description: 'z.B. "Senior Editor", "Gastautor", "Chefredakteur"', + }, + }, + // Kontakt & Social + { + name: 'email', + type: 'email', + label: 'E-Mail', + admin: { + description: 'Öffentliche Kontakt-E-Mail (optional)', + }, + }, + { + name: 'website', + type: 'text', + label: 'Website', + admin: { + description: 'Persönliche Website oder Blog', + }, + }, + { + name: 'social', + type: 'group', + label: 'Social Media', + fields: [ + { + name: 'twitter', + type: 'text', + label: 'Twitter/X', + admin: { + description: 'Twitter-Handle ohne @ (z.B. "maxmustermann")', + }, + }, + { + name: 'linkedin', + type: 'text', + label: 'LinkedIn', + admin: { + description: 'LinkedIn-Profil-URL oder Username', + }, + }, + { + name: 'github', + type: 'text', + label: 'GitHub', + admin: { + description: 'GitHub-Username', + }, + }, + { + name: 'instagram', + type: 'text', + label: 'Instagram', + admin: { + description: 'Instagram-Handle ohne @', + }, + }, + ], + }, + // Verknüpfungen + { + name: 'linkedTeam', + type: 'relationship', + relationTo: 'team', + label: 'Team-Mitglied', + admin: { + position: 'sidebar', + description: 'Optional: Verknüpfung mit Team-Eintrag', + }, + }, + { + name: 'linkedUser', + type: 'relationship', + relationTo: 'users', + label: 'Benutzer', + admin: { + position: 'sidebar', + description: 'Optional: Verknüpfung mit Login-User', + }, + }, + // Status + { + name: 'isActive', + type: 'checkbox', + defaultValue: true, + label: 'Aktiv', + admin: { + position: 'sidebar', + description: 'Inaktive Autoren erscheinen nicht in Listen', + }, + }, + { + name: 'isGuest', + type: 'checkbox', + defaultValue: false, + label: 'Gastautor', + admin: { + position: 'sidebar', + description: 'Markierung für Gastautoren', + }, + }, + { + name: 'featured', + type: 'checkbox', + defaultValue: false, + label: 'Hervorgehoben', + admin: { + position: 'sidebar', + description: 'Für besondere Darstellung auf Autorenseite', + }, + }, + ], + hooks: { + beforeChange: [ + ({ data }) => { + // Auto-generate slug from name if not provided + if (data && !data.slug && data.name) { + data.slug = data.name + .toLowerCase() + .replace(/[äöüß]/g, (match: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[match] || match + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + } + return data + }, + ], + }, +} diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts index 97d1083..7f1669c 100644 --- a/src/collections/Pages.ts +++ b/src/collections/Pages.ts @@ -20,6 +20,14 @@ import { FAQBlock, TeamBlock, ServicesBlock, + // Blogging Blocks + AuthorBioBlock, + RelatedPostsBlock, + ShareButtonsBlock, + TableOfContentsBlock, + // Team Blocks + TeamFilterBlock, + OrgChartBlock, } from '../blocks' import { pagesAccess } from '../lib/access' @@ -94,6 +102,14 @@ export const Pages: CollectionConfig = { FAQBlock, TeamBlock, ServicesBlock, + // Blogging Blocks + AuthorBioBlock, + RelatedPostsBlock, + ShareButtonsBlock, + TableOfContentsBlock, + // Team Blocks + TeamFilterBlock, + OrgChartBlock, ], }, { diff --git a/src/collections/Posts.ts b/src/collections/Posts.ts index 52a8f3b..6ae66f8 100644 --- a/src/collections/Posts.ts +++ b/src/collections/Posts.ts @@ -1,6 +1,36 @@ 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 + 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: { @@ -86,11 +116,55 @@ export const Posts: CollectionConfig = { 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: 'text', + 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', @@ -143,4 +217,15 @@ export const Posts: CollectionConfig = { ], }, ], + hooks: { + beforeChange: [ + ({ data }) => { + // Automatische Lesezeit-Berechnung + if (data?.content) { + data.readingTime = calculateReadingTime(data.content) + } + return data + }, + ], + }, } diff --git a/src/collections/Tags.ts b/src/collections/Tags.ts new file mode 100644 index 0000000..632e339 --- /dev/null +++ b/src/collections/Tags.ts @@ -0,0 +1,81 @@ +import type { CollectionConfig } from 'payload' + +/** + * Tags Collection + * + * Schlagwörter für Blog-Posts und andere Inhalte. + * Flexibler als Kategorien, für freie Verschlagwortung. + */ +export const Tags: CollectionConfig = { + slug: 'tags', + labels: { + singular: 'Tag', + plural: 'Tags', + }, + admin: { + useAsTitle: 'name', + group: 'Content', + defaultColumns: ['name', 'slug', 'postCount', 'updatedAt'], + description: 'Schlagwörter für Blog-Posts', + }, + access: { + read: () => true, + create: ({ req }) => !!req.user, + update: ({ req }) => !!req.user, + delete: ({ req }) => !!req.user, + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + localized: true, + label: 'Name', + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + admin: { + description: 'URL-freundlicher Identifier (z.B. "javascript")', + }, + }, + { + name: 'description', + type: 'textarea', + localized: true, + label: 'Beschreibung', + admin: { + description: 'Optionale Beschreibung für Tag-Archivseiten', + }, + }, + { + name: 'color', + type: 'text', + label: 'Farbe', + admin: { + description: 'Optionale Farbe für Tag-Badge (z.B. "#3B82F6" oder "blue")', + }, + }, + ], + hooks: { + beforeChange: [ + ({ data }) => { + // Auto-generate slug from name if not provided + if (data && !data.slug && data.name) { + data.slug = data.name + .toLowerCase() + .replace(/[äöüß]/g, (match: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[match] || match + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + } + return data + }, + ], + }, +} diff --git a/src/collections/Team.ts b/src/collections/Team.ts index 8f2e124..848c253 100644 --- a/src/collections/Team.ts +++ b/src/collections/Team.ts @@ -34,6 +34,16 @@ export const Team: CollectionConfig = { description: 'Vollständiger Name', }, }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + admin: { + description: 'URL-freundlicher Identifier (z.B. "max-mustermann")', + }, + }, { name: 'role', type: 'text', @@ -273,5 +283,61 @@ export const Team: CollectionConfig = { description: 'Eintrittsdatum (optional)', }, }, + // Hierarchie für Organigramm + { + name: 'reportsTo', + type: 'relationship', + relationTo: 'team', + label: 'Vorgesetzter', + admin: { + position: 'sidebar', + description: 'Direkter Vorgesetzter (für Organigramm)', + }, + }, + { + name: 'hierarchyLevel', + type: 'select', + label: 'Hierarchie-Ebene', + options: [ + { label: 'Geschäftsführung', value: 'executive' }, + { label: 'Abteilungsleitung', value: 'department_head' }, + { label: 'Teamleitung', value: 'team_lead' }, + { label: 'Mitarbeiter', value: 'employee' }, + { label: 'Praktikant/Azubi', value: 'trainee' }, + ], + admin: { + position: 'sidebar', + description: 'Für Organigramm-Darstellung', + }, + }, + // vCard Export erlauben + { + name: 'allowVCard', + type: 'checkbox', + defaultValue: true, + label: 'vCard-Export erlauben', + admin: { + position: 'sidebar', + description: 'Kontaktkarte zum Download anbieten', + }, + }, ], + hooks: { + beforeChange: [ + ({ data }) => { + // Auto-generate slug from name if not provided + if (data && !data.slug && data.name) { + data.slug = data.name + .toLowerCase() + .replace(/[äöüß]/g, (match: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[match] || match + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + } + return data + }, + ], + }, } diff --git a/src/migrations/20251213_220000_blogging_collections.ts b/src/migrations/20251213_220000_blogging_collections.ts new file mode 100644 index 0000000..8bde049 --- /dev/null +++ b/src/migrations/20251213_220000_blogging_collections.ts @@ -0,0 +1,735 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Blogging Collections & Blocks + * + * Erstellt: + * - Tags Collection + * - Authors Collection + * - Posts-Erweiterungen (Tags, Author Relations, Reading Time) + * - AuthorBioBlock, RelatedPostsBlock, ShareButtonsBlock, TableOfContentsBlock + */ + +export async function up({ db }: MigrateUpArgs): Promise { + // ======================================== + // Tags Collection + // ======================================== + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "tags" ( + "id" serial PRIMARY KEY NOT NULL, + "slug" varchar NOT NULL UNIQUE, + "color" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "tags_locales" ( + "name" varchar NOT NULL, + "description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + ALTER TABLE "tags_locales" + ADD CONSTRAINT "tags_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action; + + CREATE UNIQUE INDEX IF NOT EXISTS "tags_locales_locale_parent_id_unique" + ON "tags_locales" USING btree ("_locale", "_parent_id"); + + CREATE INDEX IF NOT EXISTS "tags_created_at_idx" + ON "tags" USING btree ("created_at"); + `) + + // Tags Tenant Column (für Multi-Tenant Plugin) + await db.execute(sql` + ALTER TABLE "tags" ADD COLUMN IF NOT EXISTS "tenant_id" integer; + + ALTER TABLE "tags" + ADD CONSTRAINT "tags_tenant_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "tags_tenant_idx" + ON "tags" USING btree ("tenant_id"); + `) + + // ======================================== + // Authors Collection + // ======================================== + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "authors" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "slug" varchar NOT NULL UNIQUE, + "avatar_id" integer, + "email" varchar, + "website" varchar, + "social_twitter" varchar, + "social_linkedin" varchar, + "social_github" varchar, + "social_instagram" varchar, + "linked_team_id" integer, + "linked_user_id" integer, + "is_active" boolean DEFAULT true, + "is_guest" boolean DEFAULT false, + "featured" boolean DEFAULT false, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "authors_locales" ( + "bio" jsonb, + "bio_short" varchar, + "title" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + ALTER TABLE "authors" + ADD CONSTRAINT "authors_avatar_id_fk" + FOREIGN KEY ("avatar_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "authors" + ADD CONSTRAINT "authors_linked_team_id_fk" + FOREIGN KEY ("linked_team_id") REFERENCES "public"."team"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "authors" + ADD CONSTRAINT "authors_linked_user_id_fk" + FOREIGN KEY ("linked_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "authors_locales" + ADD CONSTRAINT "authors_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."authors"("id") ON DELETE cascade ON UPDATE no action; + + CREATE UNIQUE INDEX IF NOT EXISTS "authors_locales_locale_parent_id_unique" + ON "authors_locales" USING btree ("_locale", "_parent_id"); + + CREATE INDEX IF NOT EXISTS "authors_avatar_idx" + ON "authors" USING btree ("avatar_id"); + + CREATE INDEX IF NOT EXISTS "authors_created_at_idx" + ON "authors" USING btree ("created_at"); + `) + + // Authors Tenant Column + await db.execute(sql` + ALTER TABLE "authors" ADD COLUMN IF NOT EXISTS "tenant_id" integer; + + ALTER TABLE "authors" + ADD CONSTRAINT "authors_tenant_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "authors_tenant_idx" + ON "authors" USING btree ("tenant_id"); + `) + + // ======================================== + // Posts-Erweiterungen + // ======================================== + + // Tags Relationship (posts_rels für tags) + await db.execute(sql` + -- Add tags path to posts_rels if needed + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM posts_rels WHERE path = 'tags' LIMIT 1 + ) THEN + -- tags_id column might not exist yet + ALTER TABLE "posts_rels" ADD COLUMN IF NOT EXISTS "tags_id" integer; + + ALTER TABLE "posts_rels" + ADD CONSTRAINT "posts_rels_tags_fk" + FOREIGN KEY ("tags_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "posts_rels_tags_id_idx" + ON "posts_rels" USING btree ("tags_id"); + END IF; + END + $$; + `) + + // Author Relationship (author_id and co_authors via posts_rels) + await db.execute(sql` + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "author_id" integer; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "author_legacy" varchar; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "reading_time" integer; + + ALTER TABLE "posts" + ADD CONSTRAINT "posts_author_id_fk" + FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE set null ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "posts_author_idx" + ON "posts" USING btree ("author_id"); + + -- Co-Authors via posts_rels + ALTER TABLE "posts_rels" ADD COLUMN IF NOT EXISTS "authors_id" integer; + + ALTER TABLE "posts_rels" + ADD CONSTRAINT "posts_rels_authors_fk" + FOREIGN KEY ("authors_id") REFERENCES "public"."authors"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "posts_rels_authors_id_idx" + ON "posts_rels" USING btree ("authors_id"); + `) + + // ======================================== + // AuthorBioBlock + // ======================================== + await db.execute(sql` + -- Enums for AuthorBioBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_source" AS ENUM('post', 'manual'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_layout" AS ENUM('card', 'inline', 'compact', 'feature'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_show_bio" AS ENUM('none', 'short', 'full'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_avatar_size" AS ENUM('sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_avatar_shape" AS ENUM('circle', 'square', 'rounded'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_author_bio_bg" AS ENUM('none', 'light', 'dark', 'accent'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_author_bio_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "source" "enum_pages_blocks_author_bio_source" DEFAULT 'post', + "show_co_authors" boolean DEFAULT true, + "layout" "enum_pages_blocks_author_bio_layout" DEFAULT 'card', + "show_avatar" boolean DEFAULT true, + "show_name" boolean DEFAULT true, + "show_title" boolean DEFAULT true, + "show_bio" "enum_pages_blocks_author_bio_show_bio" DEFAULT 'short', + "show_social" boolean DEFAULT true, + "show_email" boolean DEFAULT false, + "show_website" boolean DEFAULT false, + "show_post_count" boolean DEFAULT false, + "style_avatar_size" "enum_pages_blocks_author_bio_avatar_size" DEFAULT 'md', + "style_avatar_shape" "enum_pages_blocks_author_bio_avatar_shape" DEFAULT 'circle', + "style_bg" "enum_pages_blocks_author_bio_bg" DEFAULT 'light', + "style_border" boolean DEFAULT false, + "style_shadow" boolean DEFAULT false, + "style_divider" boolean DEFAULT true, + "link_to_profile" boolean DEFAULT true, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_author_bio_block_locales" ( + "label" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + -- Authors relationship for manual selection + CREATE TABLE IF NOT EXISTS "pages_blocks_author_bio_block_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" varchar NOT NULL, + "path" varchar NOT NULL, + "authors_id" integer + ); + + ALTER TABLE "pages_blocks_author_bio_block" + ADD CONSTRAINT "pages_blocks_author_bio_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_author_bio_block_locales" + ADD CONSTRAINT "pages_blocks_author_bio_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_author_bio_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_author_bio_block_rels" + ADD CONSTRAINT "pages_blocks_author_bio_block_rels_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."pages_blocks_author_bio_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_author_bio_block_rels" + ADD CONSTRAINT "pages_blocks_author_bio_block_rels_authors_fk" + FOREIGN KEY ("authors_id") REFERENCES "public"."authors"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_author_bio_block_order_idx" + ON "pages_blocks_author_bio_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_author_bio_block_parent_id_idx" + ON "pages_blocks_author_bio_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_author_bio_block_path_idx" + ON "pages_blocks_author_bio_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_author_bio_block_locales_locale_parent_id_unique" + ON "pages_blocks_author_bio_block_locales" USING btree ("_locale", "_parent_id"); + `) + + // ======================================== + // RelatedPostsBlock + // ======================================== + await db.execute(sql` + -- Enums for RelatedPostsBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_source" AS ENUM('auto', 'manual', 'category', 'tag', 'latest', 'popular', 'author'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_layout" AS ENUM('grid', 'list', 'slider', 'compact', 'cards'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_cols" AS ENUM('2', '3', '4'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_img_ratio" AS ENUM('square', '4-3', '16-9', '3-4'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_rounded" AS ENUM('none', 'sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_hover" AS ENUM('none', 'lift', 'zoom', 'overlay'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_bg" AS ENUM('none', 'light', 'dark'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_related_posts_gap" AS ENUM('16', '24', '32'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_related_posts_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "source" "enum_pages_blocks_related_posts_source" DEFAULT 'auto', + "category_id" integer, + "tag_id" integer, + "limit" integer DEFAULT 3, + "exclude_current" boolean DEFAULT true, + "layout" "enum_pages_blocks_related_posts_layout" DEFAULT 'grid', + "cols" "enum_pages_blocks_related_posts_cols" DEFAULT '3', + "show_image" boolean DEFAULT true, + "show_date" boolean DEFAULT true, + "show_author" boolean DEFAULT false, + "show_category" boolean DEFAULT true, + "show_excerpt" boolean DEFAULT true, + "show_reading_time" boolean DEFAULT false, + "show_tags" boolean DEFAULT false, + "style_img_ratio" "enum_pages_blocks_related_posts_img_ratio" DEFAULT '16-9', + "style_rounded" "enum_pages_blocks_related_posts_rounded" DEFAULT 'md', + "style_shadow" boolean DEFAULT false, + "style_hover" "enum_pages_blocks_related_posts_hover" DEFAULT 'lift', + "style_bg" "enum_pages_blocks_related_posts_bg" DEFAULT 'none', + "style_gap" "enum_pages_blocks_related_posts_gap" DEFAULT '24', + "show_all_link" boolean DEFAULT false, + "all_link_url" varchar, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_related_posts_block_locales" ( + "title" varchar DEFAULT 'Das könnte Sie auch interessieren', + "all_link_text" varchar DEFAULT 'Alle Artikel anzeigen', + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + -- Posts relationship for manual selection + CREATE TABLE IF NOT EXISTS "pages_blocks_related_posts_block_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" varchar NOT NULL, + "path" varchar NOT NULL, + "posts_id" integer + ); + + ALTER TABLE "pages_blocks_related_posts_block" + ADD CONSTRAINT "pages_blocks_related_posts_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_related_posts_block" + ADD CONSTRAINT "pages_blocks_related_posts_block_category_id_fk" + FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "pages_blocks_related_posts_block" + ADD CONSTRAINT "pages_blocks_related_posts_block_tag_id_fk" + FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "pages_blocks_related_posts_block_locales" + ADD CONSTRAINT "pages_blocks_related_posts_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_related_posts_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_related_posts_block_rels" + ADD CONSTRAINT "pages_blocks_related_posts_block_rels_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."pages_blocks_related_posts_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_related_posts_block_rels" + ADD CONSTRAINT "pages_blocks_related_posts_block_rels_posts_fk" + FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_related_posts_block_order_idx" + ON "pages_blocks_related_posts_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_related_posts_block_parent_id_idx" + ON "pages_blocks_related_posts_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_related_posts_block_path_idx" + ON "pages_blocks_related_posts_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_related_posts_block_locales_locale_parent_id_unique" + ON "pages_blocks_related_posts_block_locales" USING btree ("_locale", "_parent_id"); + `) + + // ======================================== + // ShareButtonsBlock + // ======================================== + await db.execute(sql` + -- Enums for ShareButtonsBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_layout" AS ENUM('horizontal', 'vertical', 'floating', 'sticky'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_align" AS ENUM('left', 'center', 'right'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_float_side" AS ENUM('left', 'right'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_variant" AS ENUM('filled', 'outline', 'icon', 'minimal'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_size" AS ENUM('sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_shape" AS ENUM('square', 'rounded', 'circle', 'pill'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_color" AS ENUM('brand', 'gray', 'dark', 'light', 'accent'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_share_gap" AS ENUM('0', '4', '8', '12'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_share_buttons_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "platforms_facebook" boolean DEFAULT true, + "platforms_twitter" boolean DEFAULT true, + "platforms_linkedin" boolean DEFAULT true, + "platforms_xing" boolean DEFAULT false, + "platforms_whatsapp" boolean DEFAULT true, + "platforms_telegram" boolean DEFAULT false, + "platforms_email" boolean DEFAULT true, + "platforms_copy" boolean DEFAULT true, + "platforms_print" boolean DEFAULT false, + "platforms_pinterest" boolean DEFAULT false, + "platforms_reddit" boolean DEFAULT false, + "layout" "enum_pages_blocks_share_layout" DEFAULT 'horizontal', + "align" "enum_pages_blocks_share_align" DEFAULT 'left', + "float_side" "enum_pages_blocks_share_float_side" DEFAULT 'left', + "style_variant" "enum_pages_blocks_share_variant" DEFAULT 'filled', + "style_size" "enum_pages_blocks_share_size" DEFAULT 'md', + "style_shape" "enum_pages_blocks_share_shape" DEFAULT 'rounded', + "style_color_scheme" "enum_pages_blocks_share_color" DEFAULT 'brand', + "style_gap" "enum_pages_blocks_share_gap" DEFAULT '8', + "style_show_label" boolean DEFAULT false, + "style_show_count" boolean DEFAULT false, + "behavior_open_in_popup" boolean DEFAULT true, + "behavior_use_native_share" boolean DEFAULT true, + "content_hashtags" varchar, + "content_via" varchar, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_share_buttons_block_locales" ( + "label" varchar, + "behavior_copy_feedback" varchar DEFAULT 'Link kopiert!', + "content_custom_title" varchar, + "content_custom_description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + ALTER TABLE "pages_blocks_share_buttons_block" + ADD CONSTRAINT "pages_blocks_share_buttons_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_share_buttons_block_locales" + ADD CONSTRAINT "pages_blocks_share_buttons_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_share_buttons_block"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_share_buttons_block_order_idx" + ON "pages_blocks_share_buttons_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_share_buttons_block_parent_id_idx" + ON "pages_blocks_share_buttons_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_share_buttons_block_path_idx" + ON "pages_blocks_share_buttons_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_share_buttons_block_locales_locale_parent_id_unique" + ON "pages_blocks_share_buttons_block_locales" USING btree ("_locale", "_parent_id"); + `) + + // ======================================== + // TableOfContentsBlock + // ======================================== + await db.execute(sql` + -- Enums for TableOfContentsBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_layout" AS ENUM('list', 'numbered', 'inline', 'sidebar', 'dropdown'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_sidebar_pos" AS ENUM('left', 'right'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_progress_style" AS ENUM('bar', 'percent', 'circle'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_bg" AS ENUM('none', 'light', 'dark', 'accent'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_border_side" AS ENUM('all', 'left', 'top'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_rounded" AS ENUM('none', 'sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_font_size" AS ENUM('sm', 'base', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_toc_line_height" AS ENUM('tight', 'normal', 'relaxed'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_toc_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "levels_h2" boolean DEFAULT true, + "levels_h3" boolean DEFAULT true, + "levels_h4" boolean DEFAULT false, + "levels_h5" boolean DEFAULT false, + "levels_h6" boolean DEFAULT false, + "layout" "enum_pages_blocks_toc_layout" DEFAULT 'list', + "sidebar_pos" "enum_pages_blocks_toc_sidebar_pos" DEFAULT 'right', + "behavior_smooth_scroll" boolean DEFAULT true, + "behavior_highlight_active" boolean DEFAULT true, + "behavior_scroll_offset" integer DEFAULT 80, + "behavior_collapsible" boolean DEFAULT false, + "behavior_start_collapsed" boolean DEFAULT false, + "behavior_show_progress" boolean DEFAULT false, + "behavior_progress_style" "enum_pages_blocks_toc_progress_style" DEFAULT 'bar', + "style_bg" "enum_pages_blocks_toc_bg" DEFAULT 'light', + "style_border" boolean DEFAULT true, + "style_border_side" "enum_pages_blocks_toc_border_side" DEFAULT 'left', + "style_rounded" "enum_pages_blocks_toc_rounded" DEFAULT 'md', + "style_shadow" boolean DEFAULT false, + "style_indent" boolean DEFAULT true, + "style_show_icon" boolean DEFAULT false, + "style_font_size" "enum_pages_blocks_toc_font_size" DEFAULT 'sm', + "style_line_height" "enum_pages_blocks_toc_line_height" DEFAULT 'relaxed', + "min_items" integer DEFAULT 3, + "max_items" integer, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_toc_block_locales" ( + "title" varchar DEFAULT 'Inhaltsverzeichnis', + "a11y_label" varchar DEFAULT 'Inhaltsverzeichnis', + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + ALTER TABLE "pages_blocks_toc_block" + ADD CONSTRAINT "pages_blocks_toc_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_toc_block_locales" + ADD CONSTRAINT "pages_blocks_toc_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_toc_block"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_toc_block_order_idx" + ON "pages_blocks_toc_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_toc_block_parent_id_idx" + ON "pages_blocks_toc_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_toc_block_path_idx" + ON "pages_blocks_toc_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_toc_block_locales_locale_parent_id_unique" + ON "pages_blocks_toc_block_locales" USING btree ("_locale", "_parent_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Drop Blocks + await db.execute(sql` + DROP TABLE IF EXISTS "pages_blocks_toc_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_toc_block" CASCADE; + + DROP TABLE IF EXISTS "pages_blocks_share_buttons_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_share_buttons_block" CASCADE; + + DROP TABLE IF EXISTS "pages_blocks_related_posts_block_rels" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_related_posts_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_related_posts_block" CASCADE; + + DROP TABLE IF EXISTS "pages_blocks_author_bio_block_rels" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_author_bio_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_author_bio_block" CASCADE; + `) + + // Drop Block Enums + await db.execute(sql` + DROP TYPE IF EXISTS "enum_pages_blocks_toc_line_height"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_font_size"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_rounded"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_border_side"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_progress_style"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_sidebar_pos"; + DROP TYPE IF EXISTS "enum_pages_blocks_toc_layout"; + + DROP TYPE IF EXISTS "enum_pages_blocks_share_gap"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_color"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_shape"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_size"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_variant"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_float_side"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_align"; + DROP TYPE IF EXISTS "enum_pages_blocks_share_layout"; + + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_gap"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_hover"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_rounded"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_img_ratio"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_cols"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_layout"; + DROP TYPE IF EXISTS "enum_pages_blocks_related_posts_source"; + + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_avatar_shape"; + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_avatar_size"; + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_show_bio"; + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_layout"; + DROP TYPE IF EXISTS "enum_pages_blocks_author_bio_source"; + `) + + // Drop Posts extensions + await db.execute(sql` + ALTER TABLE "posts" DROP COLUMN IF EXISTS "author_id"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "author_legacy"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "reading_time"; + ALTER TABLE "posts_rels" DROP COLUMN IF EXISTS "authors_id"; + ALTER TABLE "posts_rels" DROP COLUMN IF EXISTS "tags_id"; + `) + + // Drop Authors + await db.execute(sql` + DROP TABLE IF EXISTS "authors_locales" CASCADE; + DROP TABLE IF EXISTS "authors" CASCADE; + `) + + // Drop Tags + await db.execute(sql` + DROP TABLE IF EXISTS "tags_locales" CASCADE; + DROP TABLE IF EXISTS "tags" CASCADE; + `) +} diff --git a/src/migrations/20251213_230000_team_extensions.ts b/src/migrations/20251213_230000_team_extensions.ts new file mode 100644 index 0000000..fa33222 --- /dev/null +++ b/src/migrations/20251213_230000_team_extensions.ts @@ -0,0 +1,451 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Team Extensions + * + * Erweitert die Team Collection um: + * - Slug für Einzelseiten + * - Hierarchie-Felder (reportsTo, hierarchyLevel) für Organigramm + * - vCard-Export-Flag + * + * Erstellt neue Blocks: + * - TeamFilterBlock + * - OrgChartBlock + */ + +export async function up({ db }: MigrateUpArgs): Promise { + // ======================================== + // Team Collection Extensions + // ======================================== + + // Add slug field + await db.execute(sql` + ALTER TABLE "team" ADD COLUMN IF NOT EXISTS "slug" varchar UNIQUE; + + CREATE INDEX IF NOT EXISTS "team_slug_idx" + ON "team" USING btree ("slug"); + `) + + // Add hierarchy fields + await db.execute(sql` + DO $$ + BEGIN + CREATE TYPE "enum_team_hierarchy_level" AS ENUM('executive', 'department_head', 'team_lead', 'employee', 'trainee'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + ALTER TABLE "team" ADD COLUMN IF NOT EXISTS "reports_to_id" integer; + ALTER TABLE "team" ADD COLUMN IF NOT EXISTS "hierarchy_level" "enum_team_hierarchy_level"; + ALTER TABLE "team" ADD COLUMN IF NOT EXISTS "allow_v_card" boolean DEFAULT true; + + ALTER TABLE "team" + ADD CONSTRAINT "team_reports_to_id_fk" + FOREIGN KEY ("reports_to_id") REFERENCES "public"."team"("id") ON DELETE set null ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "team_reports_to_idx" + ON "team" USING btree ("reports_to_id"); + CREATE INDEX IF NOT EXISTS "team_hierarchy_level_idx" + ON "team" USING btree ("hierarchy_level"); + `) + + // ======================================== + // TeamFilterBlock + // ======================================== + await db.execute(sql` + -- Enums for TeamFilterBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_filter_layout" AS ENUM('horizontal', 'sidebar', 'dropdown', 'tabs'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_filter_style" AS ENUM('buttons', 'select', 'checkbox'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_display_layout" AS ENUM('grid', 'list', 'compact'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_display_cols" AS ENUM('2', '3', '4'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_load_more" AS ENUM('button', 'infinite', 'pagination', 'all'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_card_img_style" AS ENUM('circle', 'rounded', 'square'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_style_bg" AS ENUM('none', 'light', 'dark'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_style_card_bg" AS ENUM('white', 'transparent', 'light'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_style_hover" AS ENUM('none', 'lift', 'shadow', 'border'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_style_gap" AS ENUM('16', '24', '32'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_team_filter_style_anim" AS ENUM('none', 'fade', 'slide', 'scale'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_team_filter_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + -- Filters + "filters_show_search" boolean DEFAULT true, + "filters_show_department" boolean DEFAULT true, + "filters_show_specialization" boolean DEFAULT false, + "filters_show_language" boolean DEFAULT false, + "filters_show_hierarchy" boolean DEFAULT false, + "filters_filter_layout" "enum_pages_blocks_team_filter_filter_layout" DEFAULT 'horizontal', + "filters_filter_style" "enum_pages_blocks_team_filter_filter_style" DEFAULT 'buttons', + "filters_show_result_count" boolean DEFAULT true, + "filters_show_reset_button" boolean DEFAULT true, + -- Display + "display_layout" "enum_pages_blocks_team_filter_display_layout" DEFAULT 'grid', + "display_columns" "enum_pages_blocks_team_filter_display_cols" DEFAULT '3', + "display_initial_limit" integer DEFAULT 12, + "display_load_more" "enum_pages_blocks_team_filter_load_more" DEFAULT 'button', + -- Card + "card_show_image" boolean DEFAULT true, + "card_image_style" "enum_pages_blocks_team_filter_card_img_style" DEFAULT 'circle', + "card_show_role" boolean DEFAULT true, + "card_show_department" boolean DEFAULT true, + "card_show_bio" boolean DEFAULT false, + "card_show_contact" boolean DEFAULT false, + "card_show_social" boolean DEFAULT true, + "card_show_specializations" boolean DEFAULT false, + "card_show_languages" boolean DEFAULT false, + "card_show_v_card" boolean DEFAULT false, + "card_link_to_profile" boolean DEFAULT true, + "card_profile_base_path" varchar DEFAULT '/team', + "card_enable_modal" boolean DEFAULT false, + -- Style + "style_bg" "enum_pages_blocks_team_filter_style_bg" DEFAULT 'none', + "style_card_bg" "enum_pages_blocks_team_filter_style_card_bg" DEFAULT 'white', + "style_card_shadow" boolean DEFAULT true, + "style_card_hover" "enum_pages_blocks_team_filter_style_hover" DEFAULT 'lift', + "style_gap" "enum_pages_blocks_team_filter_style_gap" DEFAULT '24', + "style_animation" "enum_pages_blocks_team_filter_style_anim" DEFAULT 'fade', + -- Empty state + "empty_state_show_reset_button" boolean DEFAULT true, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_team_filter_block_locales" ( + "title" varchar DEFAULT 'Unser Team', + "subtitle" varchar, + "filters_search_placeholder" varchar DEFAULT 'Team durchsuchen...', + "display_load_more_text" varchar DEFAULT 'Mehr anzeigen', + "empty_state_title" varchar DEFAULT 'Keine Mitarbeiter gefunden', + "empty_state_message" varchar DEFAULT 'Versuchen Sie andere Filterkriterien.', + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + ALTER TABLE "pages_blocks_team_filter_block" + ADD CONSTRAINT "pages_blocks_team_filter_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_team_filter_block_locales" + ADD CONSTRAINT "pages_blocks_team_filter_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_team_filter_block"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_team_filter_block_order_idx" + ON "pages_blocks_team_filter_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_team_filter_block_parent_id_idx" + ON "pages_blocks_team_filter_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_team_filter_block_path_idx" + ON "pages_blocks_team_filter_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_team_filter_block_locales_locale_parent_id_unique" + ON "pages_blocks_team_filter_block_locales" USING btree ("_locale", "_parent_id"); + `) + + // ======================================== + // OrgChartBlock + // ======================================== + await db.execute(sql` + -- Enums for OrgChartBlock + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_source" AS ENUM('auto', 'department', 'manual'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_layout" AS ENUM('tree', 'tree-horizontal', 'org', 'radial', 'layers'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_direction" AS ENUM('top', 'bottom', 'left', 'right'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_node_style" AS ENUM('card', 'compact', 'avatar', 'text'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_node_img_size" AS ENUM('sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_node_img_shape" AS ENUM('circle', 'rounded', 'square'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_node_click" AS ENUM('none', 'modal', 'link', 'expand'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_conn_style" AS ENUM('straight', 'angular', 'curved', 'dashed'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_conn_color" AS ENUM('gray', 'dark', 'accent', 'gradient'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_conn_thick" AS ENUM('1', '2', '3'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_style_bg" AS ENUM('none', 'light', 'white', 'dark', 'gradient'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_style_node_bg" AS ENUM('white', 'light', 'transparent', 'accent'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_style_spacing" AS ENUM('sm', 'md', 'lg'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ + BEGIN + CREATE TYPE "enum_pages_blocks_org_chart_style_height" AS ENUM('auto', '300', '400', '500', '600', 'full'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_blocks_org_chart_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + -- Source + "source" "enum_pages_blocks_org_chart_source" DEFAULT 'auto', + "root_member_id" integer, + "department" varchar, + "max_depth" integer DEFAULT 5, + -- Layout + "layout" "enum_pages_blocks_org_chart_layout" DEFAULT 'tree', + "direction" "enum_pages_blocks_org_chart_direction" DEFAULT 'top', + -- Node + "node_style" "enum_pages_blocks_org_chart_node_style" DEFAULT 'card', + "node_show_image" boolean DEFAULT true, + "node_image_size" "enum_pages_blocks_org_chart_node_img_size" DEFAULT 'md', + "node_image_shape" "enum_pages_blocks_org_chart_node_img_shape" DEFAULT 'circle', + "node_show_name" boolean DEFAULT true, + "node_show_role" boolean DEFAULT true, + "node_show_department" boolean DEFAULT false, + "node_show_contact" boolean DEFAULT false, + "node_click_action" "enum_pages_blocks_org_chart_node_click" DEFAULT 'modal', + "node_profile_base_path" varchar DEFAULT '/team', + -- Connectors + "connectors_style" "enum_pages_blocks_org_chart_conn_style" DEFAULT 'straight', + "connectors_color" "enum_pages_blocks_org_chart_conn_color" DEFAULT 'gray', + "connectors_thickness" "enum_pages_blocks_org_chart_conn_thick" DEFAULT '2', + "connectors_animated" boolean DEFAULT false, + -- Levels + "levels_color_by_level" boolean DEFAULT true, + "levels_size_by_level" boolean DEFAULT true, + "levels_collapsible" boolean DEFAULT true, + "levels_initially_expanded" integer DEFAULT 2, + -- Interaction + "interaction_zoomable" boolean DEFAULT true, + "interaction_pannable" boolean DEFAULT true, + "interaction_minimap" boolean DEFAULT false, + "interaction_search" boolean DEFAULT false, + "interaction_highlight" boolean DEFAULT true, + "interaction_fullscreen" boolean DEFAULT true, + "interaction_export" boolean DEFAULT false, + -- Style + "style_bg" "enum_pages_blocks_org_chart_style_bg" DEFAULT 'light', + "style_node_bg" "enum_pages_blocks_org_chart_style_node_bg" DEFAULT 'white', + "style_node_shadow" boolean DEFAULT true, + "style_node_border" boolean DEFAULT false, + "style_spacing" "enum_pages_blocks_org_chart_style_spacing" DEFAULT 'md', + "style_min_height" "enum_pages_blocks_org_chart_style_height" DEFAULT '400', + -- Other + "show_legend" boolean DEFAULT false, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_org_chart_block_locales" ( + "title" varchar DEFAULT 'Unsere Struktur', + "subtitle" varchar, + "a11y_label" varchar DEFAULT 'Organigramm der Unternehmensstruktur', + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL + ); + + -- Selected members relationship + CREATE TABLE IF NOT EXISTS "pages_blocks_org_chart_block_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" varchar NOT NULL, + "path" varchar NOT NULL, + "team_id" integer + ); + + ALTER TABLE "pages_blocks_org_chart_block" + ADD CONSTRAINT "pages_blocks_org_chart_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_org_chart_block" + ADD CONSTRAINT "pages_blocks_org_chart_block_root_member_id_fk" + FOREIGN KEY ("root_member_id") REFERENCES "public"."team"("id") ON DELETE set null ON UPDATE no action; + + ALTER TABLE "pages_blocks_org_chart_block_locales" + ADD CONSTRAINT "pages_blocks_org_chart_block_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_org_chart_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_org_chart_block_rels" + ADD CONSTRAINT "pages_blocks_org_chart_block_rels_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."pages_blocks_org_chart_block"("id") ON DELETE cascade ON UPDATE no action; + + ALTER TABLE "pages_blocks_org_chart_block_rels" + ADD CONSTRAINT "pages_blocks_org_chart_block_rels_team_fk" + FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action; + + CREATE INDEX IF NOT EXISTS "pages_blocks_org_chart_block_order_idx" + ON "pages_blocks_org_chart_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_org_chart_block_parent_id_idx" + ON "pages_blocks_org_chart_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_org_chart_block_path_idx" + ON "pages_blocks_org_chart_block" USING btree ("_path"); + + CREATE UNIQUE INDEX IF NOT EXISTS "pages_blocks_org_chart_block_locales_locale_parent_id_unique" + ON "pages_blocks_org_chart_block_locales" USING btree ("_locale", "_parent_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Drop OrgChartBlock + await db.execute(sql` + DROP TABLE IF EXISTS "pages_blocks_org_chart_block_rels" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_org_chart_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_org_chart_block" CASCADE; + `) + + // Drop OrgChartBlock enums + await db.execute(sql` + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_style_height"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_style_spacing"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_style_node_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_style_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_conn_thick"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_conn_color"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_conn_style"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_node_click"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_node_img_shape"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_node_img_size"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_node_style"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_direction"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_layout"; + DROP TYPE IF EXISTS "enum_pages_blocks_org_chart_source"; + `) + + // Drop TeamFilterBlock + await db.execute(sql` + DROP TABLE IF EXISTS "pages_blocks_team_filter_block_locales" CASCADE; + DROP TABLE IF EXISTS "pages_blocks_team_filter_block" CASCADE; + `) + + // Drop TeamFilterBlock enums + await db.execute(sql` + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_style_anim"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_style_gap"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_style_hover"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_style_card_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_style_bg"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_card_img_style"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_load_more"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_display_cols"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_display_layout"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_filter_style"; + DROP TYPE IF EXISTS "enum_pages_blocks_team_filter_filter_layout"; + `) + + // Drop Team extensions + await db.execute(sql` + ALTER TABLE "team" DROP COLUMN IF EXISTS "slug"; + ALTER TABLE "team" DROP COLUMN IF EXISTS "reports_to_id"; + ALTER TABLE "team" DROP COLUMN IF EXISTS "hierarchy_level"; + ALTER TABLE "team" DROP COLUMN IF EXISTS "allow_v_card"; + DROP TYPE IF EXISTS "enum_team_hierarchy_level"; + `) +} diff --git a/src/payload-types.ts b/src/payload-types.ts index 47026d5..6f59b4d 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -86,6 +86,8 @@ export interface Config { products: Product; timelines: Timeline; workflows: Workflow; + tags: Tag; + authors: Author; 'cookie-configurations': CookieConfiguration; 'cookie-inventory': CookieInventory; 'consent-logs': ConsentLog; @@ -121,6 +123,8 @@ export interface Config { products: ProductsSelect | ProductsSelect; timelines: TimelinesSelect | TimelinesSelect; workflows: WorkflowsSelect | WorkflowsSelect; + tags: TagsSelect | TagsSelect; + authors: AuthorsSelect | AuthorsSelect; 'cookie-configurations': CookieConfigurationsSelect | CookieConfigurationsSelect; 'cookie-inventory': CookieInventorySelect | CookieInventorySelect; 'consent-logs': ConsentLogsSelect | ConsentLogsSelect; @@ -1101,6 +1105,377 @@ export interface Page { blockName?: string | null; blockType: 'services-block'; } + | { + /** + * Woher sollen die Autoren-Daten kommen? + */ + source?: ('post' | 'manual') | null; + /** + * Wählen Sie die anzuzeigenden Autoren + */ + authors?: (number | Author)[] | null; + /** + * Auch Co-Autoren des Artikels anzeigen + */ + showCoAuthors?: boolean | null; + layout?: ('card' | 'inline' | 'compact' | 'feature') | null; + show?: { + avatar?: boolean | null; + name?: boolean | null; + title?: boolean | null; + bio?: ('none' | 'short' | 'full') | null; + social?: boolean | null; + email?: boolean | null; + website?: boolean | null; + postCount?: boolean | null; + }; + style?: { + avatarSize?: ('sm' | 'md' | 'lg') | null; + avatarShape?: ('circle' | 'square' | 'rounded') | null; + bg?: ('none' | 'light' | 'dark' | 'accent') | null; + border?: boolean | null; + shadow?: boolean | null; + /** + * Linie zur Trennung vom Artikel-Inhalt + */ + divider?: boolean | null; + }; + /** + * z.B. "Über den Autor", "Geschrieben von" (optional) + */ + label?: string | null; + linkToProfile?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'author-bio-block'; + } + | { + title?: string | null; + source?: ('auto' | 'manual' | 'category' | 'tag' | 'latest' | 'popular' | 'author') | null; + /** + * Wählen Sie die anzuzeigenden Artikel + */ + posts?: (number | Post)[] | null; + category?: (number | null) | Category; + tag?: (number | null) | Tag; + limit?: number | null; + /** + * Den Artikel, auf dem dieser Block ist, nicht anzeigen + */ + excludeCurrent?: boolean | null; + layout?: ('grid' | 'list' | 'slider' | 'compact' | 'cards') | null; + cols?: ('2' | '3' | '4') | null; + show?: { + image?: boolean | null; + date?: boolean | null; + author?: boolean | null; + category?: boolean | null; + excerpt?: boolean | null; + readingTime?: boolean | null; + tags?: boolean | null; + }; + style?: { + imgRatio?: ('square' | '4-3' | '16-9' | '3-4') | null; + rounded?: ('none' | 'sm' | 'md' | 'lg') | null; + shadow?: boolean | null; + hover?: ('none' | 'lift' | 'zoom' | 'overlay') | null; + bg?: ('none' | 'light' | 'dark') | null; + gap?: ('16' | '24' | '32') | null; + }; + showAllLink?: boolean | null; + allLinkText?: string | null; + /** + * z.B. /blog oder /news + */ + allLinkUrl?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'related-posts-block'; + } + | { + /** + * z.B. "Teilen:", "Artikel teilen" (optional) + */ + label?: string | null; + platforms?: { + facebook?: boolean | null; + twitter?: boolean | null; + linkedin?: boolean | null; + xing?: boolean | null; + whatsapp?: boolean | null; + telegram?: boolean | null; + email?: boolean | null; + copy?: boolean | null; + print?: boolean | null; + pinterest?: boolean | null; + reddit?: boolean | null; + }; + layout?: ('horizontal' | 'vertical' | 'floating' | 'sticky') | null; + align?: ('left' | 'center' | 'right') | null; + floatSide?: ('left' | 'right') | null; + style?: { + variant?: ('filled' | 'outline' | 'icon' | 'minimal') | null; + size?: ('sm' | 'md' | 'lg') | null; + shape?: ('square' | 'rounded' | 'circle' | 'pill') | null; + colorScheme?: ('brand' | 'gray' | 'dark' | 'light' | 'accent') | null; + gap?: ('0' | '4' | '8' | '12') | null; + showLabel?: boolean | null; + /** + * Zeigt Anzahl der Shares (sofern verfügbar) + */ + showCount?: boolean | null; + }; + behavior?: { + /** + * Share-Dialoge in kleinem Popup statt neuem Tab + */ + openInPopup?: boolean | null; + /** + * Auf mobilen Geräten den System-Share-Dialog verwenden + */ + useNativeShare?: boolean | null; + /** + * Text der angezeigt wird nach dem Kopieren + */ + copyFeedback?: string | null; + }; + /** + * Überschreibt automatische Werte (optional) + */ + content?: { + /** + * Leer lassen für automatischen Seitentitel + */ + customTitle?: string | null; + /** + * Leer lassen für automatische Meta-Description + */ + customDescription?: string | null; + /** + * Komma-getrennt, ohne # (z.B. "blog,news,tech") + */ + hashtags?: string | null; + /** + * Twitter-Handle ohne @ (z.B. "c2sgmbh") + */ + via?: string | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'share-buttons-block'; + } + | { + title?: string | null; + /** + * Welche Überschriften-Ebenen einschließen? + */ + levels?: { + h2?: boolean | null; + h3?: boolean | null; + h4?: boolean | null; + h5?: boolean | null; + h6?: boolean | null; + }; + layout?: ('list' | 'numbered' | 'inline' | 'sidebar' | 'dropdown') | null; + sidebarPos?: ('left' | 'right') | null; + behavior?: { + smoothScroll?: boolean | null; + /** + * Markiert den aktuell sichtbaren Abschnitt + */ + highlightActive?: boolean | null; + /** + * Abstand zum oberen Rand nach dem Scrollen (für Fixed Headers) + */ + scrollOffset?: number | null; + /** + * User kann das Inhaltsverzeichnis ein-/ausklappen + */ + collapsible?: boolean | null; + startCollapsed?: boolean | null; + /** + * Fortschrittsbalken oder Prozent-Anzeige + */ + showProgress?: boolean | null; + progressStyle?: ('bar' | 'percent' | 'circle') | null; + }; + style?: { + bg?: ('none' | 'light' | 'dark' | 'accent') | null; + border?: boolean | null; + borderSide?: ('all' | 'left' | 'top') | null; + rounded?: ('none' | 'sm' | 'md' | 'lg') | null; + shadow?: boolean | null; + indent?: boolean | null; + /** + * Link-Symbol neben Einträgen + */ + showIcon?: boolean | null; + fontSize?: ('sm' | 'base' | 'lg') | null; + lineHeight?: ('tight' | 'normal' | 'relaxed') | null; + }; + /** + * Inhaltsverzeichnis nur anzeigen, wenn mindestens X Einträge + */ + minItems?: number | null; + /** + * Maximale Anzahl angezeigter Einträge (0 = unbegrenzt) + */ + maxItems?: number | null; + a11yLabel?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'toc-block'; + } + | { + title?: string | null; + subtitle?: string | null; + filters?: { + showSearch?: boolean | null; + searchPlaceholder?: string | null; + showDepartment?: boolean | null; + showSpecialization?: boolean | null; + showLanguage?: boolean | null; + showHierarchy?: boolean | null; + filterLayout?: ('horizontal' | 'sidebar' | 'dropdown' | 'tabs') | null; + filterStyle?: ('buttons' | 'select' | 'checkbox') | null; + showResultCount?: boolean | null; + showResetButton?: boolean | null; + }; + display?: { + layout?: ('grid' | 'list' | 'compact') | null; + columns?: ('2' | '3' | '4') | null; + initialLimit?: number | null; + loadMore?: ('button' | 'infinite' | 'pagination' | 'all') | null; + loadMoreText?: string | null; + }; + card?: { + showImage?: boolean | null; + imageStyle?: ('circle' | 'rounded' | 'square') | null; + showRole?: boolean | null; + showDepartment?: boolean | null; + showBio?: boolean | null; + showContact?: boolean | null; + showSocial?: boolean | null; + showSpecializations?: boolean | null; + showLanguages?: boolean | null; + showVCard?: boolean | null; + linkToProfile?: boolean | null; + /** + * z.B. "/team" ergibt "/team/max-mustermann" + */ + profileBasePath?: string | null; + /** + * Klick öffnet Modal statt Profilseite + */ + enableModal?: boolean | null; + }; + style?: { + bg?: ('none' | 'light' | 'dark') | null; + cardBg?: ('white' | 'transparent' | 'light') | null; + cardShadow?: boolean | null; + cardHover?: ('none' | 'lift' | 'shadow' | 'border') | null; + gap?: ('16' | '24' | '32') | null; + animation?: ('none' | 'fade' | 'slide' | 'scale') | null; + }; + emptyState?: { + title?: string | null; + message?: string | null; + showResetButton?: boolean | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'team-filter-block'; + } + | { + title?: string | null; + subtitle?: string | null; + source?: ('auto' | 'department' | 'manual') | null; + /** + * Oberstes Element (z.B. Geschäftsführer). Leer = automatisch ermitteln. + */ + rootMember?: (number | null) | Team; + /** + * Zeigt nur diese Abteilung + */ + department?: string | null; + selectedMembers?: (number | Team)[] | null; + /** + * Wie viele Hierarchie-Ebenen anzeigen + */ + maxDepth?: number | null; + layout?: ('tree' | 'tree-horizontal' | 'org' | 'radial' | 'layers') | null; + direction?: ('top' | 'bottom' | 'left' | 'right') | null; + node?: { + style?: ('card' | 'compact' | 'avatar' | 'text') | null; + showImage?: boolean | null; + imageSize?: ('sm' | 'md' | 'lg') | null; + imageShape?: ('circle' | 'rounded' | 'square') | null; + showName?: boolean | null; + showRole?: boolean | null; + showDepartment?: boolean | null; + showContact?: boolean | null; + clickAction?: ('none' | 'modal' | 'link' | 'expand') | null; + profileBasePath?: string | null; + }; + connectors?: { + style?: ('straight' | 'angular' | 'curved' | 'dashed') | null; + color?: ('gray' | 'dark' | 'accent' | 'gradient') | null; + thickness?: ('1' | '2' | '3') | null; + /** + * Animierte Linien beim Laden + */ + animated?: boolean | null; + }; + levels?: { + /** + * Verschiedene Farben pro Hierarchie-Ebene + */ + colorByLevel?: boolean | null; + /** + * Höhere Ebenen größer darstellen + */ + sizeByLevel?: boolean | null; + collapsible?: boolean | null; + /** + * Wie viele Ebenen anfangs sichtbar + */ + initiallyExpanded?: number | null; + }; + interaction?: { + zoomable?: boolean | null; + pannable?: boolean | null; + /** + * Kleine Übersichtskarte bei großen Organigrammen + */ + minimap?: boolean | null; + search?: boolean | null; + /** + * Bei Hover Pfad zur Wurzel hervorheben + */ + highlight?: boolean | null; + fullscreen?: boolean | null; + /** + * Als PNG oder PDF exportieren + */ + export?: boolean | null; + }; + style?: { + bg?: ('none' | 'light' | 'white' | 'dark' | 'gradient') | null; + nodeBg?: ('white' | 'light' | 'transparent' | 'accent') | null; + nodeShadow?: boolean | null; + nodeBorder?: boolean | null; + spacing?: ('sm' | 'md' | 'lg') | null; + minHeight?: ('auto' | '300' | '400' | '500' | '600' | 'full') | null; + }; + /** + * Erklärt Farben und Symbole + */ + showLegend?: boolean | null; + a11yLabel?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'org-chart-block'; + } )[] | null; seo?: { @@ -1248,6 +1623,10 @@ export interface Team { * Vollständiger Name */ name: string; + /** + * URL-freundlicher Identifier (z.B. "max-mustermann") + */ + slug: string; /** * z.B. "Geschäftsführer", "Pflegedienstleitung", "Fotograf" */ @@ -1342,6 +1721,18 @@ export interface Team { * Eintrittsdatum (optional) */ startDate?: string | null; + /** + * Direkter Vorgesetzter (für Organigramm) + */ + reportsTo?: (number | null) | Team; + /** + * Für Organigramm-Darstellung + */ + hierarchyLevel?: ('executive' | 'department_head' | 'team_lead' | 'employee' | 'trainee') | null; + /** + * Kontaktkarte zum Download anbieten + */ + allowVCard?: boolean | null; updatedAt: string; createdAt: string; } @@ -1582,6 +1973,102 @@ export interface Service { updatedAt: string; createdAt: string; } +/** + * Blog-Autoren und Gastautoren + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "authors". + */ +export interface Author { + id: number; + tenant?: (number | null) | Tenant; + /** + * Anzeigename des Autors + */ + name: string; + /** + * URL-freundlicher Identifier (z.B. "max-mustermann") + */ + slug: string; + /** + * Avatar/Profilbild (empfohlen: quadratisch, min. 200x200px) + */ + avatar?: (number | null) | Media; + /** + * Ausführliche Biografie für Autorenseite + */ + bio?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + /** + * Ein bis zwei Sätze für Anzeige unter Artikeln + */ + bioShort?: string | null; + /** + * z.B. "Senior Editor", "Gastautor", "Chefredakteur" + */ + title?: string | null; + /** + * Öffentliche Kontakt-E-Mail (optional) + */ + email?: string | null; + /** + * Persönliche Website oder Blog + */ + website?: string | null; + social?: { + /** + * Twitter-Handle ohne @ (z.B. "maxmustermann") + */ + twitter?: string | null; + /** + * LinkedIn-Profil-URL oder Username + */ + linkedin?: string | null; + /** + * GitHub-Username + */ + github?: string | null; + /** + * Instagram-Handle ohne @ + */ + instagram?: string | null; + }; + /** + * Optional: Verknüpfung mit Team-Eintrag + */ + linkedTeam?: (number | null) | Team; + /** + * Optional: Verknüpfung mit Login-User + */ + linkedUser?: (number | null) | User; + /** + * Inaktive Autoren erscheinen nicht in Listen + */ + isActive?: boolean | null; + /** + * Markierung für Gastautoren + */ + isGuest?: boolean | null; + /** + * Für besondere Darstellung auf Autorenseite + */ + featured?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". @@ -1623,7 +2110,26 @@ export interface Post { [k: string]: unknown; }; categories?: (number | Category)[] | null; - author?: string | null; + /** + * Schlagwörter für bessere Auffindbarkeit + */ + tags?: (number | Tag)[] | null; + /** + * Hauptautor des Beitrags + */ + author?: (number | null) | Author; + /** + * Weitere beteiligte Autoren + */ + coAuthors?: (number | Author)[] | null; + /** + * Freitext-Autor für ältere Beiträge ohne Autoren-Eintrag + */ + authorLegacy?: string | null; + /** + * Wird automatisch berechnet + */ + readingTime?: number | null; status?: ('draft' | 'published' | 'archived') | null; publishedAt?: string | null; seo?: { @@ -1634,6 +2140,31 @@ export interface Post { updatedAt: string; createdAt: string; } +/** + * Schlagwörter für Blog-Posts + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags". + */ +export interface Tag { + id: number; + tenant?: (number | null) | Tenant; + name: string; + /** + * URL-freundlicher Identifier (z.B. "javascript") + */ + slug: string; + /** + * Optionale Beschreibung für Tag-Archivseiten + */ + description?: string | null; + /** + * Optionale Farbe für Tag-Badge (z.B. "#3B82F6" oder "blue") + */ + color?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "social-links". @@ -3102,6 +3633,14 @@ export interface PayloadLockedDocument { relationTo: 'workflows'; value: number | Workflow; } | null) + | ({ + relationTo: 'tags'; + value: number | Tag; + } | null) + | ({ + relationTo: 'authors'; + value: number | Author; + } | null) | ({ relationTo: 'cookie-configurations'; value: number | CookieConfiguration; @@ -3951,6 +4490,308 @@ export interface PagesSelect { id?: T; blockName?: T; }; + 'author-bio-block'?: + | T + | { + source?: T; + authors?: T; + showCoAuthors?: T; + layout?: T; + show?: + | T + | { + avatar?: T; + name?: T; + title?: T; + bio?: T; + social?: T; + email?: T; + website?: T; + postCount?: T; + }; + style?: + | T + | { + avatarSize?: T; + avatarShape?: T; + bg?: T; + border?: T; + shadow?: T; + divider?: T; + }; + label?: T; + linkToProfile?: T; + id?: T; + blockName?: T; + }; + 'related-posts-block'?: + | T + | { + title?: T; + source?: T; + posts?: T; + category?: T; + tag?: T; + limit?: T; + excludeCurrent?: T; + layout?: T; + cols?: T; + show?: + | T + | { + image?: T; + date?: T; + author?: T; + category?: T; + excerpt?: T; + readingTime?: T; + tags?: T; + }; + style?: + | T + | { + imgRatio?: T; + rounded?: T; + shadow?: T; + hover?: T; + bg?: T; + gap?: T; + }; + showAllLink?: T; + allLinkText?: T; + allLinkUrl?: T; + id?: T; + blockName?: T; + }; + 'share-buttons-block'?: + | T + | { + label?: T; + platforms?: + | T + | { + facebook?: T; + twitter?: T; + linkedin?: T; + xing?: T; + whatsapp?: T; + telegram?: T; + email?: T; + copy?: T; + print?: T; + pinterest?: T; + reddit?: T; + }; + layout?: T; + align?: T; + floatSide?: T; + style?: + | T + | { + variant?: T; + size?: T; + shape?: T; + colorScheme?: T; + gap?: T; + showLabel?: T; + showCount?: T; + }; + behavior?: + | T + | { + openInPopup?: T; + useNativeShare?: T; + copyFeedback?: T; + }; + content?: + | T + | { + customTitle?: T; + customDescription?: T; + hashtags?: T; + via?: T; + }; + id?: T; + blockName?: T; + }; + 'toc-block'?: + | T + | { + title?: T; + levels?: + | T + | { + h2?: T; + h3?: T; + h4?: T; + h5?: T; + h6?: T; + }; + layout?: T; + sidebarPos?: T; + behavior?: + | T + | { + smoothScroll?: T; + highlightActive?: T; + scrollOffset?: T; + collapsible?: T; + startCollapsed?: T; + showProgress?: T; + progressStyle?: T; + }; + style?: + | T + | { + bg?: T; + border?: T; + borderSide?: T; + rounded?: T; + shadow?: T; + indent?: T; + showIcon?: T; + fontSize?: T; + lineHeight?: T; + }; + minItems?: T; + maxItems?: T; + a11yLabel?: T; + id?: T; + blockName?: T; + }; + 'team-filter-block'?: + | T + | { + title?: T; + subtitle?: T; + filters?: + | T + | { + showSearch?: T; + searchPlaceholder?: T; + showDepartment?: T; + showSpecialization?: T; + showLanguage?: T; + showHierarchy?: T; + filterLayout?: T; + filterStyle?: T; + showResultCount?: T; + showResetButton?: T; + }; + display?: + | T + | { + layout?: T; + columns?: T; + initialLimit?: T; + loadMore?: T; + loadMoreText?: T; + }; + card?: + | T + | { + showImage?: T; + imageStyle?: T; + showRole?: T; + showDepartment?: T; + showBio?: T; + showContact?: T; + showSocial?: T; + showSpecializations?: T; + showLanguages?: T; + showVCard?: T; + linkToProfile?: T; + profileBasePath?: T; + enableModal?: T; + }; + style?: + | T + | { + bg?: T; + cardBg?: T; + cardShadow?: T; + cardHover?: T; + gap?: T; + animation?: T; + }; + emptyState?: + | T + | { + title?: T; + message?: T; + showResetButton?: T; + }; + id?: T; + blockName?: T; + }; + 'org-chart-block'?: + | T + | { + title?: T; + subtitle?: T; + source?: T; + rootMember?: T; + department?: T; + selectedMembers?: T; + maxDepth?: T; + layout?: T; + direction?: T; + node?: + | T + | { + style?: T; + showImage?: T; + imageSize?: T; + imageShape?: T; + showName?: T; + showRole?: T; + showDepartment?: T; + showContact?: T; + clickAction?: T; + profileBasePath?: T; + }; + connectors?: + | T + | { + style?: T; + color?: T; + thickness?: T; + animated?: T; + }; + levels?: + | T + | { + colorByLevel?: T; + sizeByLevel?: T; + collapsible?: T; + initiallyExpanded?: T; + }; + interaction?: + | T + | { + zoomable?: T; + pannable?: T; + minimap?: T; + search?: T; + highlight?: T; + fullscreen?: T; + export?: T; + }; + style?: + | T + | { + bg?: T; + nodeBg?: T; + nodeShadow?: T; + nodeBorder?: T; + spacing?: T; + minHeight?: T; + }; + showLegend?: T; + a11yLabel?: T; + id?: T; + blockName?: T; + }; }; seo?: | T @@ -3978,7 +4819,11 @@ export interface PostsSelect { featuredImage?: T; content?: T; categories?: T; + tags?: T; author?: T; + coAuthors?: T; + authorLegacy?: T; + readingTime?: T; status?: T; publishedAt?: T; seo?: @@ -4060,6 +4905,7 @@ export interface FaqsSelect { export interface TeamSelect { tenant?: T; name?: T; + slug?: T; role?: T; department?: T; image?: T; @@ -4101,6 +4947,9 @@ export interface TeamSelect { isFeatured?: T; order?: T; startDate?: T; + reportsTo?: T; + hierarchyLevel?: T; + allowVCard?: T; updatedAt?: T; createdAt?: T; } @@ -4584,6 +5433,49 @@ export interface WorkflowsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags_select". + */ +export interface TagsSelect { + tenant?: T; + name?: T; + slug?: T; + description?: T; + color?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "authors_select". + */ +export interface AuthorsSelect { + tenant?: T; + name?: T; + slug?: T; + avatar?: T; + bio?: T; + bioShort?: T; + title?: T; + email?: T; + website?: T; + social?: + | T + | { + twitter?: T; + linkedin?: T; + github?: T; + instagram?: T; + }; + linkedTeam?: T; + linkedUser?: T; + isActive?: T; + isGuest?: T; + featured?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "cookie-configurations_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index bef41c1..172f9d8 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -44,6 +44,10 @@ import { Timelines } from './collections/Timelines' // Workflow Collection import { Workflows } from './collections/Workflows' +// Blogging Collections +import { Tags } from './collections/Tags' +import { Authors } from './collections/Authors' + // Consent Management Collections import { CookieConfigurations } from './collections/CookieConfigurations' import { CookieInventory } from './collections/CookieInventory' @@ -161,6 +165,9 @@ export default buildConfig({ // Timelines & Workflows Timelines, Workflows, + // Blogging + Tags, + Authors, // Consent Management CookieConfigurations, CookieInventory, @@ -212,6 +219,9 @@ export default buildConfig({ // Timeline & Workflow Collections timelines: {}, workflows: {}, + // Blogging Collections + tags: {}, + authors: {}, // Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben 'cookie-configurations': { customTenantField: true }, 'cookie-inventory': { customTenantField: true },