cms.c2sgmbh/prompts/youtube2.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- 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>
2026-01-18 10:18:05 +00:00

2068 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
1. **Script Editor** Custom Lexical Blocks für strukturierte Video-Skripte
2. **Kalenderansichten** Content Calendar + Batch Overview als Custom Admin Views
3. **Batch-Planung** Neue Collection für detaillierte Produktions-Batches
4. **Monthly Goals** KPI-Tracking pro Kanal und Monat
5. **Erweiterte YouTube Content Felder** Produktions- und Posting-Informationen
---
## Teil 1: Script Section Block
### Datei: `src/blocks/ScriptSection.ts`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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:
```typescript
// === 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`
```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`
```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)
```typescript
// 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`
```typescript
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
1. **Block erstellen:** `src/blocks/ScriptSection.ts`
2. **Collections erstellen:**
- `src/collections/youtube/YtBatches.ts`
- `src/collections/youtube/YtMonthlyGoals.ts`
- `src/collections/youtube/YtScriptTemplates.ts`
3. **YouTubeContent erweitern:** Neue Felder hinzufügen
4. **payload.config.ts aktualisieren:** Neue Collections registrieren
5. **Custom Views erstellen:**
- `src/app/(payload)/admin/views/youtube/content-calendar/page.tsx`
- `src/app/(payload)/admin/views/youtube/content-calendar/calendar.scss`
6. **Migration erstellen:** `npx payload migrate:create youtube_ops_v2`
7. **Migration ausführen:** `npx payload migrate`
8. **Seed ausführen:** Optional für Beispieldaten
---
## Testschritte
1. [ ] Server starten: `npm run dev`
2. [ ] Neue Collections in Admin UI sichtbar unter "YouTube"
3. [ ] Production Batch erstellen
4. [ ] Video mit Script Sections erstellen
5. [ ] Content Calendar View aufrufen: `/admin/youtube/calendar`
6. [ ] Monthly Goals erstellen und prüfen
---
## Hinweise
- **TypeScript Errors:** Bei Type-Fehlern `npm run generate:types` ausfü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.