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>
2068 lines
54 KiB
Markdown
2068 lines
54 KiB
Markdown
# 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.
|