mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
54 KiB
54 KiB
YouTube Operations Hub v2 – Claude Code Integration Prompt
Projektkontext
Du arbeitest am YouTube Operations Hub v2 für Complex Care Solutions (CCS). Dies ist eine Erweiterung des bestehenden YouTube-Moduls in Payload CMS 3.x.
Server: sv-payload (10.10.181.100)
Projekt-Pfad: /var/www/payload-main (oder aktueller Projektpfad)
Datenbank: PostgreSQL 17 auf sv-postgres (10.10.181.101)
Tech Stack: Payload CMS 3.x, Next.js 15, TypeScript, Drizzle ORM
Aufgabe
Erweitere das bestehende YouTube Operations Hub um:
- Script Editor – Custom Lexical Blocks für strukturierte Video-Skripte
- Kalenderansichten – Content Calendar + Batch Overview als Custom Admin Views
- Batch-Planung – Neue Collection für detaillierte Produktions-Batches
- Monthly Goals – KPI-Tracking pro Kanal und Monat
- Erweiterte YouTube Content Felder – Produktions- und Posting-Informationen
Teil 1: Script Section Block
Datei: src/blocks/ScriptSection.ts
import { Block } from 'payload'
export const ScriptSectionBlock: Block = {
slug: 'scriptSection',
labels: {
singular: { de: 'Script Section', en: 'Script Section' },
plural: { de: 'Script Sections', en: 'Script Sections' },
},
imageURL: '/assets/script-section-icon.svg',
imageAltText: 'Script Section Block',
fields: [
{
type: 'row',
fields: [
{
name: 'sectionType',
type: 'select',
required: true,
options: [
{ label: '🎬 Hook', value: 'hook' },
{ label: '🏷️ Intro/Ident', value: 'intro_ident' },
{ label: '📖 Context', value: 'context' },
{ label: '📚 Content Part', value: 'content_part' },
{ label: '📋 Summary', value: 'summary' },
{ label: '📢 CTA', value: 'cta' },
{ label: '🎬 Outro', value: 'outro' },
{ label: '⚠️ Disclaimer', value: 'disclaimer' },
],
admin: {
width: '50%',
},
},
{
name: 'duration',
type: 'text',
label: { de: 'Dauer', en: 'Duration' },
admin: {
width: '25%',
placeholder: 'z.B. "2-3 Min"',
},
},
{
name: 'sectionTitle',
type: 'text',
label: { de: 'Section-Titel', en: 'Section Title' },
admin: {
width: '25%',
placeholder: 'z.B. "TEIL 1: OUTFIT"',
condition: (data, siblingData) => siblingData?.sectionType === 'content_part',
},
},
],
},
{
name: 'spokenText',
type: 'richText',
label: { de: 'Gesprochener Text', en: 'Spoken Text' },
required: true,
localized: true,
},
{
name: 'bRollInstructions',
type: 'array',
label: { de: 'B-Roll Anweisungen', en: 'B-Roll Instructions' },
labels: {
singular: { de: 'B-Roll', en: 'B-Roll' },
plural: { de: 'B-Roll Anweisungen', en: 'B-Roll Instructions' },
},
fields: [
{
type: 'row',
fields: [
{
name: 'instruction',
type: 'text',
required: true,
admin: {
width: '70%',
placeholder: 'z.B. "Outfit zeigen", "Kleiderschrank-Aufnahme"',
},
},
{
name: 'timestamp',
type: 'text',
admin: {
width: '30%',
placeholder: 'Optional: "bei 0:45"',
},
},
],
},
],
admin: {
initCollapsed: true,
},
},
{
name: 'textOverlays',
type: 'array',
label: { de: 'Text-Overlays', en: 'Text Overlays' },
labels: {
singular: { de: 'Overlay', en: 'Overlay' },
plural: { de: 'Text-Overlays', en: 'Text Overlays' },
},
fields: [
{
type: 'row',
fields: [
{
name: 'text',
type: 'text',
required: true,
admin: {
width: '70%',
placeholder: 'z.B. "3 Kombinationen = 0 Entscheidungen"',
},
},
{
name: 'style',
type: 'select',
defaultValue: 'standard',
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Highlight', value: 'highlight' },
{ label: 'Quote', value: 'quote' },
{ label: 'Statistik', value: 'statistic' },
{ label: 'Liste', value: 'list' },
],
admin: { width: '30%' },
},
],
},
],
admin: {
initCollapsed: true,
},
},
{
name: 'visualNotes',
type: 'textarea',
label: { de: 'Visuelle Notizen', en: 'Visual Notes' },
admin: {
rows: 2,
placeholder: 'Zusätzliche Anweisungen für Schnitt/Grafik',
},
},
],
}
Teil 2: Neue Collections
Datei: src/collections/youtube/YtBatches.ts
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, isYouTubeCreatorOrAbove, hasYouTubeAccess } from '../../lib/youtubeAccess'
export const YtBatches: CollectionConfig = {
slug: 'yt-batches',
labels: {
singular: { de: 'Production Batch', en: 'Production Batch' },
plural: { de: 'Production Batches', en: 'Production Batches' },
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'channel', 'status', 'productionPeriod.start', 'progress.percentage'],
listSearchableFields: ['name'],
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeCreatorOrAbove,
delete: isYouTubeManager,
},
fields: [
// === BASIC INFO ===
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
label: { de: 'Batch-Name', en: 'Batch Name' },
admin: {
placeholder: 'z.B. "Januar Woche 1" oder "Batch 1"',
width: '50%',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: { de: 'Kanal', en: 'Channel' },
admin: {
width: '50%',
},
},
],
},
// === PRODUKTIONSZEITRAUM ===
{
name: 'productionPeriod',
type: 'group',
label: { de: 'Produktionszeitraum', en: 'Production Period' },
fields: [
{
type: 'row',
fields: [
{
name: 'start',
type: 'date',
required: true,
label: { de: 'Start', en: 'Start' },
admin: {
width: '50%',
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
{
name: 'end',
type: 'date',
required: true,
label: { de: 'Ende', en: 'End' },
admin: {
width: '50%',
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
],
},
{
name: 'shootDays',
type: 'array',
label: { de: 'Drehtage', en: 'Shoot Days' },
labels: {
singular: { de: 'Drehtag', en: 'Shoot Day' },
plural: { de: 'Drehtage', en: 'Shoot Days' },
},
fields: [
{
type: 'row',
fields: [
{
name: 'date',
type: 'date',
required: true,
admin: {
width: '25%',
date: { pickerAppearance: 'dayOnly' },
},
},
{
name: 'location',
type: 'select',
label: { de: 'Location', en: 'Location' },
options: [
{ label: { de: '🏠 Home Studio', en: '🏠 Home Studio' }, value: 'home' },
{ label: { de: '🏢 Office', en: '🏢 Office' }, value: 'office' },
{ label: { de: '🚗 Unterwegs', en: '🚗 On the Go' }, value: 'mobile' },
{ label: { de: '📍 Extern', en: '📍 External' }, value: 'external' },
],
admin: { width: '25%' },
},
{
name: 'duration',
type: 'select',
label: { de: 'Dauer', en: 'Duration' },
options: [
{ label: '2h', value: '2h' },
{ label: '4h (Halbtag)', value: '4h' },
{ label: '8h (Ganztag)', value: '8h' },
],
admin: { width: '25%' },
},
{
name: 'notes',
type: 'text',
label: { de: 'Notizen', en: 'Notes' },
admin: {
width: '25%',
placeholder: 'Notizen',
},
},
],
},
],
admin: {
initCollapsed: true,
},
},
],
},
// === CONTENT TARGETS ===
{
name: 'targets',
type: 'group',
label: { de: 'Content-Ziele', en: 'Content Targets' },
fields: [
{
type: 'row',
fields: [
{
name: 'shortsTarget',
type: 'number',
label: { de: 'Shorts (Ziel)', en: 'Shorts (Target)' },
required: true,
defaultValue: 7,
min: 0,
admin: { width: '25%' },
},
{
name: 'longformsTarget',
type: 'number',
label: { de: 'Longforms (Ziel)', en: 'Longforms (Target)' },
required: true,
defaultValue: 3,
min: 0,
admin: { width: '25%' },
},
{
name: 'totalTarget',
type: 'number',
label: { de: 'Gesamt', en: 'Total' },
admin: {
width: '25%',
readOnly: true,
},
},
{
name: 'bufferDays',
type: 'number',
label: { de: 'Puffer (Tage)', en: 'Buffer (Days)' },
defaultValue: 3,
min: 0,
admin: {
width: '25%',
description: { de: 'Tage zwischen Produktion und Publish', en: 'Days between production and publish' },
},
},
],
},
],
},
// === SERIEN-VERTEILUNG ===
{
name: 'seriesDistribution',
type: 'array',
label: { de: 'Serien-Verteilung', en: 'Series Distribution' },
admin: {
description: { de: 'Welche Serien in diesem Batch produziert werden', en: 'Which series are produced in this batch' },
},
fields: [
{
type: 'row',
fields: [
{
name: 'series',
type: 'text',
required: true,
label: { de: 'Serie', en: 'Series' },
admin: {
width: '30%',
placeholder: 'z.B. GRFI',
},
},
{
name: 'shortsCount',
type: 'number',
label: 'Shorts',
defaultValue: 0,
min: 0,
admin: { width: '20%' },
},
{
name: 'longformsCount',
type: 'number',
label: 'Longforms',
defaultValue: 0,
min: 0,
admin: { width: '20%' },
},
{
name: 'priority',
type: 'select',
label: { de: 'Priorität', en: 'Priority' },
options: [
{ label: '🔴 Hoch', value: 'high' },
{ label: '🟡 Normal', value: 'normal' },
{ label: '🟢 Niedrig', value: 'low' },
],
defaultValue: 'normal',
admin: { width: '30%' },
},
],
},
],
},
// === STATUS ===
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'planning',
label: { de: 'Status', en: 'Status' },
options: [
{ label: { de: '📝 Planung', en: '📝 Planning' }, value: 'planning' },
{ label: { de: '✍️ Scripts', en: '✍️ Scripts' }, value: 'scripting' },
{ label: { de: '🎬 Produktion', en: '🎬 Production' }, value: 'production' },
{ label: { de: '✂️ Schnitt', en: '✂️ Editing' }, value: 'editing' },
{ label: { de: '✅ Review', en: '✅ Review' }, value: 'review' },
{ label: { de: '📤 Upload-Ready', en: '📤 Upload-Ready' }, value: 'ready' },
{ label: { de: '🎉 Veröffentlicht', en: '🎉 Published' }, value: 'published' },
],
admin: {
position: 'sidebar',
},
},
// === PROGRESS (berechnet) ===
{
name: 'progress',
type: 'group',
label: { de: 'Fortschritt', en: 'Progress' },
admin: {
position: 'sidebar',
readOnly: true,
},
fields: [
{
name: 'shortsCompleted',
type: 'number',
label: { de: 'Shorts fertig', en: 'Shorts Completed' },
admin: { readOnly: true },
},
{
name: 'longformsCompleted',
type: 'number',
label: { de: 'Longforms fertig', en: 'Longforms Completed' },
admin: { readOnly: true },
},
{
name: 'percentage',
type: 'number',
label: { de: 'Gesamt %', en: 'Total %' },
admin: { readOnly: true },
},
],
},
// === TEAM ===
{
name: 'team',
type: 'group',
label: { de: 'Team', en: 'Team' },
fields: [
{
type: 'row',
fields: [
{
name: 'producer',
type: 'relationship',
relationTo: 'users',
label: 'Producer',
admin: { width: '33%' },
},
{
name: 'editor',
type: 'relationship',
relationTo: 'users',
label: 'Editor',
admin: { width: '33%' },
},
{
name: 'reviewer',
type: 'relationship',
relationTo: 'users',
label: 'Reviewer',
admin: { width: '33%' },
},
],
},
],
},
// === NOTIZEN ===
{
name: 'notes',
type: 'textarea',
label: { de: 'Notizen', en: 'Notes' },
localized: true,
admin: {
rows: 4,
},
},
],
// === HOOKS ===
hooks: {
beforeChange: [
// Total Target berechnen
({ data }) => {
if (data?.targets) {
data.targets.totalTarget = (data.targets.shortsTarget || 0) + (data.targets.longformsTarget || 0)
}
return data
},
],
afterRead: [
// Progress aus verknüpften Videos berechnen
async ({ doc, req }) => {
if (!doc?.id) return doc
try {
const videos = await req.payload.find({
collection: 'youtube-content',
where: {
productionBatch: { equals: doc.id },
},
limit: 100,
depth: 0,
})
const shorts = videos.docs.filter((v: any) => v.format === 'short')
const longforms = videos.docs.filter((v: any) => v.format === 'longform')
const completedStatuses = ['approved', 'upload_scheduled', 'published', 'tracked']
const shortsCompleted = shorts.filter((v: any) =>
completedStatuses.includes(v.status)
).length
const longformsCompleted = longforms.filter((v: any) =>
completedStatuses.includes(v.status)
).length
const totalTarget = (doc.targets?.shortsTarget || 0) + (doc.targets?.longformsTarget || 0)
const totalCompleted = shortsCompleted + longformsCompleted
const percentage = totalTarget > 0 ? Math.round((totalCompleted / totalTarget) * 100) : 0
return {
...doc,
progress: {
shortsCompleted,
longformsCompleted,
percentage,
},
}
} catch (error) {
console.error('Error calculating batch progress:', error)
return doc
}
},
],
},
}
Datei: src/collections/youtube/YtMonthlyGoals.ts
import type { CollectionConfig } from 'payload'
import { isYouTubeManager, hasYouTubeAccess } from '../../lib/youtubeAccess'
export const YtMonthlyGoals: CollectionConfig = {
slug: 'yt-monthly-goals',
labels: {
singular: { de: 'Monatsziel', en: 'Monthly Goal' },
plural: { de: 'Monatsziele', en: 'Monthly Goals' },
},
admin: {
group: 'YouTube',
useAsTitle: 'displayTitle',
defaultColumns: ['displayTitle', 'channel', 'month', 'updatedAt'],
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: { de: 'Kanal', en: 'Channel' },
admin: { width: '50%' },
},
{
name: 'month',
type: 'date',
required: true,
label: { de: 'Monat', en: 'Month' },
admin: {
width: '50%',
date: {
pickerAppearance: 'monthOnly',
displayFormat: 'MMMM yyyy',
},
},
},
],
},
{
name: 'displayTitle',
type: 'text',
admin: { hidden: true },
hooks: {
beforeChange: [
async ({ siblingData, req }) => {
if (siblingData.channel && siblingData.month) {
try {
const channelId = typeof siblingData.channel === 'object'
? siblingData.channel.id
: siblingData.channel
const channel = await req.payload.findByID({
collection: 'youtube-channels',
id: channelId,
})
const monthStr = new Date(siblingData.month).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
})
return `${channel?.name || 'Kanal'} - ${monthStr}`
} catch {
return 'Neues Monatsziel'
}
}
return 'Neues Monatsziel'
},
],
},
},
// === CONTENT GOALS ===
{
name: 'contentGoals',
type: 'group',
label: { de: 'Content-Ziele', en: 'Content Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'longformsTarget',
type: 'number',
label: { de: 'Longforms (Ziel)', en: 'Longforms (Target)' },
defaultValue: 12,
admin: { width: '25%' },
},
{
name: 'longformsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
defaultValue: 0,
admin: { width: '25%' },
},
{
name: 'shortsTarget',
type: 'number',
label: { de: 'Shorts (Ziel)', en: 'Shorts (Target)' },
defaultValue: 28,
admin: { width: '25%' },
},
{
name: 'shortsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
defaultValue: 0,
admin: { width: '25%' },
},
],
},
],
},
// === AUDIENCE GOALS ===
{
name: 'audienceGoals',
type: 'group',
label: { de: 'Audience-Ziele', en: 'Audience Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'subscribersTarget',
type: 'number',
label: { de: 'Neue Abos (Ziel)', en: 'New Subs (Target)' },
admin: { width: '25%' },
},
{
name: 'subscribersCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'viewsTarget',
type: 'number',
label: { de: 'Views (Ziel)', en: 'Views (Target)' },
admin: { width: '25%' },
},
{
name: 'viewsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === ENGAGEMENT GOALS ===
{
name: 'engagementGoals',
type: 'group',
label: { de: 'Engagement-Ziele', en: 'Engagement Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'avgCtrTarget',
type: 'text',
label: { de: 'Ø CTR (Ziel)', en: 'Avg CTR (Target)' },
admin: {
width: '25%',
placeholder: 'z.B. ">4%"',
},
},
{
name: 'avgCtrCurrent',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'avgRetentionTarget',
type: 'text',
label: { de: 'Ø Retention (Ziel)', en: 'Avg Retention (Target)' },
admin: {
width: '25%',
placeholder: 'z.B. ">50%"',
},
},
{
name: 'avgRetentionCurrent',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === BUSINESS GOALS ===
{
name: 'businessGoals',
type: 'group',
label: { de: 'Business-Ziele', en: 'Business Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'newsletterSignupsTarget',
type: 'number',
label: { de: 'Newsletter-Anmeldungen (Ziel)', en: 'Newsletter Signups (Target)' },
admin: { width: '25%' },
},
{
name: 'newsletterSignupsCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
{
name: 'affiliateRevenueTarget',
type: 'number',
label: { de: 'Affiliate-Umsatz € (Ziel)', en: 'Affiliate Revenue € (Target)' },
admin: { width: '25%' },
},
{
name: 'affiliateRevenueCurrent',
type: 'number',
label: { de: 'Aktuell', en: 'Current' },
admin: { width: '25%' },
},
],
},
],
},
// === CUSTOM GOALS ===
{
name: 'customGoals',
type: 'array',
label: { de: 'Weitere Ziele', en: 'Custom Goals' },
fields: [
{
type: 'row',
fields: [
{
name: 'metric',
type: 'text',
required: true,
label: { de: 'Metrik', en: 'Metric' },
admin: {
width: '40%',
placeholder: 'z.B. "SPARK Videos"',
},
},
{
name: 'target',
type: 'text',
required: true,
label: { de: 'Ziel', en: 'Target' },
admin: {
width: '20%',
placeholder: 'Ziel',
},
},
{
name: 'current',
type: 'text',
label: { de: 'Aktuell', en: 'Current' },
admin: {
width: '20%',
placeholder: 'Aktuell',
},
},
{
name: 'status',
type: 'select',
label: 'Status',
options: [
{ label: '🟢 On Track', value: 'on_track' },
{ label: '🟡 At Risk', value: 'at_risk' },
{ label: '✅ Achieved', value: 'achieved' },
{ label: '❌ Missed', value: 'missed' },
],
admin: { width: '20%' },
},
],
},
],
},
{
name: 'notes',
type: 'textarea',
label: { de: 'Notizen / Learnings', en: 'Notes / Learnings' },
localized: true,
},
],
}
Datei: src/collections/youtube/YtScriptTemplates.ts
import type { CollectionConfig } from 'payload'
import { ScriptSectionBlock } from '../../blocks/ScriptSection'
import { isYouTubeManager, hasYouTubeAccess } from '../../lib/youtubeAccess'
export const YtScriptTemplates: CollectionConfig = {
slug: 'yt-script-templates',
labels: {
singular: { de: 'Script Template', en: 'Script Template' },
plural: { de: 'Script Templates', en: 'Script Templates' },
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'series', 'format', 'channel', 'updatedAt'],
},
access: {
read: hasYouTubeAccess,
create: isYouTubeManager,
update: isYouTubeManager,
delete: isYouTubeManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: { de: 'Template-Name', en: 'Template Name' },
admin: {
width: '50%',
placeholder: 'z.B. "GRFI Longform Template"',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
label: { de: 'Kanal', en: 'Channel' },
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'series',
type: 'text',
required: true,
label: { de: 'Serie', en: 'Series' },
admin: {
width: '50%',
placeholder: 'z.B. "GRFI", "M2M", "SPARK"',
},
},
{
name: 'format',
type: 'select',
required: true,
label: { de: 'Format', en: 'Format' },
options: [
{ label: 'Short (45-60s)', value: 'short' },
{ label: 'Longform (8-16 Min)', value: 'longform' },
],
admin: { width: '50%' },
},
],
},
{
name: 'description',
type: 'textarea',
label: { de: 'Beschreibung', en: 'Description' },
localized: true,
admin: {
rows: 2,
description: { de: 'Hinweise zur Verwendung dieses Templates', en: 'Usage notes for this template' },
},
},
{
name: 'templateSections',
type: 'blocks',
label: { de: 'Template Sections', en: 'Template Sections' },
blocks: [ScriptSectionBlock],
},
],
}
Teil 3: Erweiterung YouTube Content Collection
Datei: src/collections/youtube/YouTubeContent.ts (Erweiterungen)
Füge diese Felder zur bestehenden YouTubeContent Collection hinzu:
// === NEUE IMPORTS ===
import { ScriptSectionBlock } from '../../blocks/ScriptSection'
// === NEUE FELDER (nach den bestehenden Feldern einfügen) ===
// PRODUKTION TAB - Neue Felder
{
name: 'productionBatch',
type: 'relationship',
relationTo: 'yt-batches',
label: { de: 'Produktions-Batch', en: 'Production Batch' },
admin: {
position: 'sidebar',
},
},
{
type: 'row',
fields: [
{
name: 'productionWeek',
type: 'number',
label: { de: 'Produktionswoche', en: 'Production Week' },
min: 1,
max: 52,
admin: { width: '50%' },
},
{
name: 'calendarWeek',
type: 'number',
label: { de: 'Kalenderwoche', en: 'Calendar Week' },
min: 1,
max: 52,
admin: { width: '50%' },
},
],
},
{
name: 'productionDate',
type: 'date',
label: { de: 'Produktionsdatum', en: 'Production Date' },
admin: {
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
{
name: 'targetDuration',
type: 'text',
label: { de: 'Ziel-Dauer', en: 'Target Duration' },
admin: {
placeholder: 'z.B. "8-12 Min" oder "45-58s"',
},
},
{
name: 'bRollNotes',
type: 'textarea',
label: { de: 'B-Roll / Setting Notizen', en: 'B-Roll / Setting Notes' },
localized: true,
admin: {
rows: 2,
},
},
// POSTING TAB - Neue Felder
{
name: 'publishTime',
type: 'text',
label: { de: 'Posting-Uhrzeit', en: 'Publish Time' },
admin: {
placeholder: 'z.B. "07:00" oder "17:00"',
},
},
{
name: 'thumbnailText',
type: 'text',
label: { de: 'Thumbnail-Text', en: 'Thumbnail Text' },
admin: {
placeholder: 'z.B. "BOARD READY | 7 MIN"',
},
},
{
type: 'row',
fields: [
{
name: 'ctaType',
type: 'select',
label: { de: 'CTA-Typ', en: 'CTA Type' },
options: [
{ label: 'Link in Bio', value: 'link_in_bio' },
{ label: 'Newsletter', value: 'newsletter' },
{ label: 'Longform verlinken', value: 'longform_link' },
{ label: 'Benutzerdefiniert', value: 'custom' },
],
admin: { width: '50%' },
},
{
name: 'ctaDetail',
type: 'text',
label: { de: 'CTA-Detail', en: 'CTA Detail' },
admin: {
width: '50%',
placeholder: 'z.B. "GRFI-Checkliste"',
condition: (data) => !!data?.ctaType,
},
},
],
},
// SCRIPT TAB (neu)
{
name: 'script',
type: 'blocks',
label: { de: 'Script', en: 'Script' },
labels: {
singular: { de: 'Section', en: 'Section' },
plural: { de: 'Script Sections', en: 'Script Sections' },
},
blocks: [ScriptSectionBlock],
admin: {
description: { de: 'Strukturiertes Video-Script mit Sections', en: 'Structured video script with sections' },
},
},
// UPLOAD CHECKLIST
{
name: 'uploadChecklist',
type: 'array',
label: { de: 'Upload-Checkliste', en: 'Upload Checklist' },
admin: {
condition: (data) => ['approved', 'upload_scheduled', 'published', 'tracked'].includes(data?.status),
},
fields: [
{
type: 'row',
fields: [
{
name: 'step',
type: 'text',
required: true,
label: { de: 'Schritt', en: 'Step' },
admin: { width: '50%' },
},
{
name: 'completed',
type: 'checkbox',
label: '✓',
admin: { width: '10%' },
},
{
name: 'completedAt',
type: 'date',
label: { de: 'Erledigt am', en: 'Completed At' },
admin: {
width: '20%',
readOnly: true,
},
},
{
name: 'completedBy',
type: 'relationship',
relationTo: 'users',
label: { de: 'Von', en: 'By' },
admin: {
width: '20%',
readOnly: true,
},
},
],
},
],
},
// DISCLAIMERS
{
name: 'disclaimers',
type: 'array',
label: { de: 'Disclaimers', en: 'Disclaimers' },
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
required: true,
label: { de: 'Typ', en: 'Type' },
options: [
{ label: '⚠️ Medizinisch', value: 'medical' },
{ label: '⚖️ Rechtlich', value: 'legal' },
{ label: '🔗 Affiliate', value: 'affiliate' },
{ label: '🤝 Sponsored', value: 'sponsored' },
],
admin: { width: '25%' },
},
{
name: 'text',
type: 'text',
label: { de: 'Text', en: 'Text' },
localized: true,
admin: { width: '50%' },
},
{
name: 'placement',
type: 'select',
label: { de: 'Platzierung', en: 'Placement' },
options: [
{ label: { de: 'Gesprochen', en: 'Spoken' }, value: 'spoken' },
{ label: 'Text-Overlay', value: 'overlay' },
{ label: { de: 'Beschreibung', en: 'Description' }, value: 'description' },
{ label: { de: 'Überall', en: 'All' }, value: 'all' },
],
admin: { width: '25%' },
},
],
},
],
},
Teil 4: Custom Admin Views
Datei: src/app/(payload)/admin/views/youtube/content-calendar/page.tsx
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameMonth,
isSameDay,
addMonths,
subMonths,
} from 'date-fns'
import { de } from 'date-fns/locale'
import './calendar.scss'
interface CalendarVideo {
id: string
title: string
format: 'short' | 'longform' | 'premiere'
contentSeries: string
status: string
productionDate?: string
scheduledPublishDate?: string
publishTime?: string
channel: {
id: string
name: string
slug: string
}
}
interface Channel {
id: string
name: string
slug: string
}
export default function ContentCalendarView() {
const [currentMonth, setCurrentMonth] = useState(new Date())
const [videos, setVideos] = useState<CalendarVideo[]>([])
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('all')
const [viewType, setViewType] = useState<'production' | 'publishing'>('production')
const [loading, setLoading] = useState(true)
const fetchChannels = useCallback(async () => {
try {
const res = await fetch('/api/youtube-channels?limit=50')
const data = await res.json()
setChannels(data.docs || [])
} catch (error) {
console.error('Error fetching channels:', error)
}
}, [])
const fetchVideos = useCallback(async () => {
setLoading(true)
const start = startOfMonth(currentMonth)
const end = endOfMonth(currentMonth)
const dateField = viewType === 'production' ? 'productionDate' : 'scheduledPublishDate'
let query = `where[${dateField}][greater_than_equal]=${start.toISOString()}&where[${dateField}][less_than_equal]=${end.toISOString()}&depth=1&limit=200`
if (selectedChannel !== 'all') {
query += `&where[channel][equals]=${selectedChannel}`
}
try {
const res = await fetch(`/api/youtube-content?${query}`)
const data = await res.json()
setVideos(data.docs || [])
} catch (error) {
console.error('Error fetching videos:', error)
}
setLoading(false)
}, [currentMonth, selectedChannel, viewType])
useEffect(() => {
fetchChannels()
}, [fetchChannels])
useEffect(() => {
fetchVideos()
}, [fetchVideos])
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
idea: '#9CA3AF',
script_draft: '#60A5FA',
script_review: '#60A5FA',
script_approved: '#34D399',
shoot_scheduled: '#FBBF24',
shot: '#FBBF24',
rough_cut: '#F97316',
fine_cut: '#F97316',
final_review: '#A78BFA',
approved: '#34D399',
upload_scheduled: '#34D399',
published: '#10B981',
tracked: '#059669',
}
return colors[status] || '#9CA3AF'
}
const renderCalendar = () => {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart, { locale: de, weekStartsOn: 1 })
const calendarEnd = endOfWeek(monthEnd, { locale: de, weekStartsOn: 1 })
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
const dateField = viewType === 'production' ? 'productionDate' : 'scheduledPublishDate'
return (
<div className="calendar-grid">
<div className="calendar-header">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => (
<div key={day} className="calendar-header-cell">
{day}
</div>
))}
</div>
<div className="calendar-body">
{days.map((day) => {
const dayVideos = videos.filter(
(v) => v[dateField as keyof CalendarVideo] && isSameDay(new Date(v[dateField as keyof CalendarVideo] as string), day)
)
return (
<div
key={day.toISOString()}
className={`calendar-cell ${!isSameMonth(day, currentMonth) ? 'outside-month' : ''} ${isSameDay(day, new Date()) ? 'today' : ''}`}
>
<div className="calendar-date">{format(day, 'd')}</div>
<div className="calendar-videos">
{dayVideos.map((video) => (
<a
key={video.id}
href={`/admin/collections/youtube-content/${video.id}`}
className={`calendar-video ${video.format}`}
style={{ borderLeftColor: getStatusColor(video.status) }}
title={`${video.title} (${video.status})`}
>
<span className="video-series">{video.contentSeries}</span>
<span className="video-title">{video.title}</span>
{video.publishTime && viewType === 'publishing' && (
<span className="video-time">{video.publishTime}</span>
)}
</a>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
return (
<div className="content-calendar-view">
<header className="calendar-controls">
<h1>📅 Content Calendar</h1>
<div className="controls-row">
<div className="month-navigation">
<button
type="button"
onClick={() => setCurrentMonth((prev) => subMonths(prev, 1))}
>
← Vorheriger
</button>
<h2>{format(currentMonth, 'MMMM yyyy', { locale: de })}</h2>
<button
type="button"
onClick={() => setCurrentMonth((prev) => addMonths(prev, 1))}
>
Nächster →
</button>
</div>
<div className="filters">
<select
value={viewType}
onChange={(e) => setViewType(e.target.value as 'production' | 'publishing')}
>
<option value="production">🎬 Produktion</option>
<option value="publishing">📤 Veröffentlichung</option>
</select>
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
>
<option value="all">Alle Kanäle</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
</div>
</div>
<div className="calendar-legend">
<div className="legend-section">
<span className="legend-title">Format:</span>
<span className="legend-item short">Short</span>
<span className="legend-item longform">Longform</span>
</div>
<div className="legend-section">
<span className="legend-title">Status:</span>
<span className="legend-item" style={{ '--status-color': '#9CA3AF' } as React.CSSProperties}>
Idee
</span>
<span className="legend-item" style={{ '--status-color': '#60A5FA' } as React.CSSProperties}>
Script
</span>
<span className="legend-item" style={{ '--status-color': '#FBBF24' } as React.CSSProperties}>
Produktion
</span>
<span className="legend-item" style={{ '--status-color': '#F97316' } as React.CSSProperties}>
Schnitt
</span>
<span className="legend-item" style={{ '--status-color': '#10B981' } as React.CSSProperties}>
Fertig
</span>
</div>
</div>
</header>
{loading ? (
<div className="loading">
<div className="loading-spinner" />
<span>Lade Kalender...</span>
</div>
) : (
renderCalendar()
)}
</div>
)
}
Datei: src/app/(payload)/admin/views/youtube/content-calendar/calendar.scss
.content-calendar-view {
padding: 20px;
max-width: 1600px;
margin: 0 auto;
font-family: var(--font-body);
}
.calendar-controls {
margin-bottom: 24px;
h1 {
margin: 0 0 16px 0;
font-size: 24px;
font-weight: 600;
}
}
.controls-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.month-navigation {
display: flex;
align-items: center;
gap: 16px;
button {
padding: 8px 16px;
border: 1px solid var(--theme-elevation-150);
border-radius: 4px;
background: var(--theme-elevation-50);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&:hover {
background: var(--theme-elevation-100);
}
}
h2 {
min-width: 200px;
text-align: center;
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.filters {
display: flex;
gap: 12px;
select {
padding: 8px 12px;
border: 1px solid var(--theme-elevation-150);
border-radius: 4px;
background: var(--theme-elevation-50);
font-size: 14px;
cursor: pointer;
}
}
.calendar-legend {
display: flex;
gap: 24px;
margin-top: 16px;
padding: 12px 16px;
background: var(--theme-elevation-50);
border-radius: 8px;
flex-wrap: wrap;
.legend-section {
display: flex;
align-items: center;
gap: 12px;
}
.legend-title {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: var(--theme-elevation-500);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 2px;
background: var(--status-color, #9CA3AF);
}
&.short::before {
background: #3B82F6;
}
&.longform::before {
background: #8B5CF6;
}
}
}
// Calendar Grid
.calendar-grid {
border: 1px solid var(--theme-elevation-150);
border-radius: 8px;
overflow: hidden;
background: var(--theme-elevation-0);
}
.calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--theme-elevation-100);
border-bottom: 1px solid var(--theme-elevation-150);
}
.calendar-header-cell {
padding: 12px;
text-align: center;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: var(--theme-elevation-600);
}
.calendar-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.calendar-cell {
min-height: 140px;
border-right: 1px solid var(--theme-elevation-100);
border-bottom: 1px solid var(--theme-elevation-100);
padding: 8px;
background: var(--theme-elevation-0);
transition: background 0.2s;
&:nth-child(7n) {
border-right: none;
}
&.outside-month {
background: var(--theme-elevation-50);
opacity: 0.6;
}
&.today {
background: rgba(59, 130, 246, 0.05);
.calendar-date {
background: #3B82F6;
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
}
&:hover {
background: var(--theme-elevation-50);
}
}
.calendar-date {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
color: var(--theme-elevation-800);
}
.calendar-videos {
display: flex;
flex-direction: column;
gap: 4px;
}
.calendar-video {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: 4px;
font-size: 11px;
text-decoration: none;
color: white;
border-left: 3px solid;
transition: transform 0.2s, opacity 0.2s;
&.short {
background: #3B82F6;
}
&.longform {
background: #8B5CF6;
}
&.premiere {
background: #EC4899;
}
&:hover {
opacity: 0.9;
transform: translateX(2px);
}
.video-series {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
opacity: 0.9;
}
.video-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.video-time {
font-size: 10px;
opacity: 0.8;
margin-top: 2px;
}
}
// Loading
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px;
gap: 16px;
color: var(--theme-elevation-500);
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--theme-elevation-150);
border-top-color: #3B82F6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Responsive
@media (max-width: 1200px) {
.calendar-cell {
min-height: 120px;
}
.calendar-video {
padding: 4px 6px;
.video-title {
display: none;
}
}
}
@media (max-width: 768px) {
.controls-row {
flex-direction: column;
align-items: stretch;
}
.month-navigation {
justify-content: center;
}
.filters {
justify-content: center;
}
.calendar-cell {
min-height: 80px;
padding: 4px;
}
.calendar-date {
font-size: 12px;
}
.calendar-video {
.video-series {
font-size: 9px;
}
}
}
Teil 5: Payload Config Update
Datei: payload.config.ts (Erweiterungen)
// Neue Imports
import { YtBatches } from './collections/youtube/YtBatches'
import { YtMonthlyGoals } from './collections/youtube/YtMonthlyGoals'
import { YtScriptTemplates } from './collections/youtube/YtScriptTemplates'
// In der collections Array hinzufügen:
collections: [
// ... bestehende Collections
YtBatches,
YtMonthlyGoals,
YtScriptTemplates,
],
// Admin Config für Custom Views
admin: {
// ... bestehende Config
components: {
views: {
// Custom YouTube Views
YouTubeContentCalendar: {
Component: '/app/(payload)/admin/views/youtube/content-calendar/page',
path: '/youtube/calendar',
},
},
},
},
Teil 6: Seed-Daten (BlogWoman Januar)
Datei: src/seed/blogwoman-januar.ts
import type { Payload } from 'payload'
export async function seedBlogWomanJanuar(payload: Payload) {
console.log('🌱 Seeding BlogWoman Januar Content...')
// 1. BlogWoman Kanal finden
const channels = await payload.find({
collection: 'youtube-channels',
where: { slug: { equals: 'blogwoman' } },
limit: 1,
})
let blogwomanChannel = channels.docs[0]
if (!blogwomanChannel) {
// Kanal erstellen falls nicht vorhanden
blogwomanChannel = await payload.create({
collection: 'youtube-channels',
data: {
name: 'BlogWoman by Caroline Porwoll',
slug: 'blogwoman',
language: 'de',
category: 'lifestyle',
status: 'active',
contentSeries: [
{ name: 'GRFI', slug: 'grfi', description: 'Get Ready For Impact', color: '#3B82F6', isActive: true },
{ name: 'Investment-Piece', slug: 'investment', description: 'Cost-per-Wear Analysen', color: '#8B5CF6', isActive: true },
{ name: 'Pleasure P&L', slug: 'pleasure-pl', description: 'ROI auf Genuss', color: '#EC4899', isActive: true },
{ name: 'M2M', slug: 'm2m', description: 'Meeting to Mom Mode', color: '#F59E0B', isActive: true },
{ name: 'SPARK', slug: 'spark', description: 'Die Flamme', color: '#EF4444', isActive: true },
{ name: 'Regeneration', slug: 'regeneration', description: 'Energie ohne Kitsch', color: '#10B981', isActive: true },
{ name: 'Decision-Proof', slug: 'decision-proof', description: 'Regeln statt Willenskraft', color: '#6366F1', isActive: true },
{ name: 'Sunday Reset', slug: 'sunday-reset', description: 'Wochenplanung', color: '#14B8A6', isActive: true },
],
publishingSchedule: {
defaultDays: ['sunday', 'wednesday', 'saturday'],
defaultTime: '07:00',
shortsPerWeek: 7,
longformPerWeek: 3,
},
},
})
console.log('✅ BlogWoman Kanal erstellt')
}
// 2. Januar Batch erstellen
const januarBatch1 = await payload.create({
collection: 'yt-batches',
data: {
name: 'Januar Woche 1',
channel: blogwomanChannel.id,
productionPeriod: {
start: '2026-01-06',
end: '2026-01-10',
},
shootDays: [
{ date: '2026-01-06', location: 'home', duration: '4h', notes: 'Longforms + Shorts' },
{ date: '2026-01-08', location: 'home', duration: '4h', notes: 'SPARK + M2M' },
],
targets: {
shortsTarget: 7,
longformsTarget: 3,
totalTarget: 10,
bufferDays: 3,
},
seriesDistribution: [
{ series: 'GRFI', shortsCount: 2, longformsCount: 1, priority: 'high' },
{ series: 'Investment-Piece', shortsCount: 1, longformsCount: 1, priority: 'high' },
{ series: 'SPARK', shortsCount: 1, longformsCount: 1, priority: 'high' },
{ series: 'M2M', shortsCount: 1, longformsCount: 0, priority: 'normal' },
{ series: 'Regeneration', shortsCount: 1, longformsCount: 0, priority: 'normal' },
{ series: 'Decision-Proof', shortsCount: 1, longformsCount: 0, priority: 'normal' },
],
status: 'planning',
},
})
console.log('✅ Januar Batch 1 erstellt')
// 3. Beispiel-Content erstellen
const exampleVideos = [
{
title: '7 Minuten: Boardroom-ready (mein komplettes System)',
contentSeries: 'GRFI',
format: 'longform',
status: 'idea',
productionBatch: januarBatch1.id,
productionDate: '2026-01-06',
scheduledPublishDate: '2026-01-12',
publishTime: '10:00',
targetDuration: '8-12 Min',
hook: 'Board-Meeting in 20 Minuten. Der Morgen war Chaos, die Kinder waren anstrengend, der Kaffee kalt. Aber das Meeting muss sitzen. So werde ich in 7 Minuten ready.',
thumbnailText: 'BOARD READY | 7 MIN',
ctaType: 'newsletter',
ctaDetail: 'GRFI-Checkliste',
bRollNotes: 'Kleiderschrank, Spiegel, Badezimmer, 3 Outfit-Kombinationen zeigen',
},
{
title: '200€ Blazer: Cost-per-Wear nach 2 Jahren',
contentSeries: 'Investment-Piece',
format: 'longform',
status: 'idea',
productionBatch: januarBatch1.id,
productionDate: '2026-01-07',
scheduledPublishDate: '2026-01-15',
publishTime: '17:00',
targetDuration: '10-14 Min',
hook: '200 Euro für einen Blazer. Klingt viel? Ich hab die Rechnung gemacht. Nach 2 Jahren. Mit echten Zahlen.',
thumbnailText: '200€ | DIE RECHNUNG',
ctaType: 'newsletter',
ctaDetail: 'CPW-Rechner',
bRollNotes: 'Blazer in 3 Kontexten: Board-Meeting, Office, Abend',
},
{
title: 'Call endet in 5 Min – Kita-Abholung in 20',
contentSeries: 'M2M',
format: 'short',
status: 'idea',
productionBatch: januarBatch1.id,
productionDate: '2026-01-06',
scheduledPublishDate: '2026-01-13',
publishTime: '07:00',
targetDuration: '45-58s',
hook: 'Video-Call endet in 5 Minuten. Kita-Abholung in 20. So switch ich.',
thumbnailText: 'BUSINESS → MAMA',
ctaType: 'link_in_bio',
bRollNotes: 'Business → Casual Transformation',
},
]
for (const video of exampleVideos) {
await payload.create({
collection: 'youtube-content',
data: {
...video,
channel: blogwomanChannel.id,
priority: 'normal',
},
})
}
console.log(`✅ ${exampleVideos.length} Beispiel-Videos erstellt`)
// 4. Monatsziele erstellen
await payload.create({
collection: 'yt-monthly-goals',
data: {
channel: blogwomanChannel.id,
month: '2026-01-01',
contentGoals: {
longformsTarget: 12,
longformsCurrent: 0,
shortsTarget: 28,
shortsCurrent: 0,
},
audienceGoals: {
subscribersTarget: 500,
subscribersCurrent: 0,
viewsTarget: 50000,
viewsCurrent: 0,
},
engagementGoals: {
avgCtrTarget: '>4%',
avgRetentionTarget: '>50%',
},
businessGoals: {
newsletterSignupsTarget: 500,
newsletterSignupsCurrent: 0,
},
customGoals: [
{ metric: 'SPARK Videos', target: '6', current: '0', status: 'on_track' },
{ metric: 'Shorts Retention', target: '>60%', status: 'on_track' },
],
},
})
console.log('✅ Januar Monatsziele erstellt')
console.log('🎉 BlogWoman Januar Seeding abgeschlossen!')
}
Ausführungsreihenfolge
- Block erstellen:
src/blocks/ScriptSection.ts - Collections erstellen:
src/collections/youtube/YtBatches.tssrc/collections/youtube/YtMonthlyGoals.tssrc/collections/youtube/YtScriptTemplates.ts
- YouTubeContent erweitern: Neue Felder hinzufügen
- payload.config.ts aktualisieren: Neue Collections registrieren
- Custom Views erstellen:
src/app/(payload)/admin/views/youtube/content-calendar/page.tsxsrc/app/(payload)/admin/views/youtube/content-calendar/calendar.scss
- Migration erstellen:
npx payload migrate:create youtube_ops_v2 - Migration ausführen:
npx payload migrate - Seed ausführen: Optional für Beispieldaten
Testschritte
- Server starten:
npm run dev - Neue Collections in Admin UI sichtbar unter "YouTube"
- Production Batch erstellen
- Video mit Script Sections erstellen
- Content Calendar View aufrufen:
/admin/youtube/calendar - Monthly Goals erstellen und prüfen
Hinweise
- TypeScript Errors: Bei Type-Fehlern
npm run generate:typesausführen - Migration Errors: Datenbank-Schema prüfen, ggf. Reset mit
npx payload migrate:reset - SCSS kompilieren: Payload kompiliert SCSS automatisch
- Lokalisierung: Alle user-facing Texte sind DE/EN lokalisiert
Bei Fragen oder Problemen: Logs prüfen und Fehler melden.