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>
1830 lines
48 KiB
Markdown
1830 lines
48 KiB
Markdown
# YouTube Operations Hub v2 – Vollständiges Konzept
|
||
|
||
## Übersicht der Erweiterungen
|
||
|
||
### Neue Features:
|
||
1. **Script Editor** – Custom Lexical Rich-Text-Editor mit Section-Blöcken
|
||
2. **Kalenderansichten** – Content Calendar + Posting Calendar direkt in Payload
|
||
3. **Batch-Planung** – Detaillierte Produktions-Batches mit Kapazitätsplanung
|
||
4. **Monthly Goals Dashboard** – KPI-Tracking pro Kanal und Monat
|
||
5. **Upload Workflow** – Strukturierte Checklisten mit Fortschrittsanzeige
|
||
|
||
---
|
||
|
||
## 1. Script Editor – Custom Lexical Blocks
|
||
|
||
### 1.1 Konzept
|
||
|
||
Payload 3.x verwendet Lexical als Rich-Text-Editor. Wir erstellen Custom Blocks für die Skript-Struktur:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 📝 Script Editor │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ 🎬 HOOK [0-10 Sek] [Edit]│ │
|
||
│ │ "Board-Meeting in 20 Minuten. Der Morgen war │ │
|
||
│ │ Chaos, die Kinder waren anstrengend..." │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ 🏷️ INTRO-IDENT [1-2 Sek] [Edit]│ │
|
||
│ │ [GRFI Serien-Badge einblenden] │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ 📖 CONTEXT [45-60 Sek] [Edit]│ │
|
||
│ │ "Kennst du das? Du wachst auf mit einem Plan..." │ │
|
||
│ │ │ │
|
||
│ │ 🎥 B-Roll: Morgenroutine, Meetings │ │
|
||
│ │ 📝 Overlay: "System schlägt Motivation" │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ 📚 TEIL 1: OUTFIT-LOGIK [2-3 Min] [Edit]│ │
|
||
│ │ "Fangen wir mit der Basis an: dem Outfit..." │ │
|
||
│ │ │ │
|
||
│ │ 🎥 B-Roll: Outfit zeigen, 3 Kombinationen │ │
|
||
│ │ 📝 Overlay: "3 Kombinationen = 0 Entscheidungen" │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ [+ Section hinzufügen ▾] │
|
||
│ • Hook │
|
||
│ • Intro/Ident │
|
||
│ • Context │
|
||
│ • Content Part │
|
||
│ • Summary │
|
||
│ • CTA │
|
||
│ • Outro │
|
||
│ • Disclaimer │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.2 Script Section Block Schema
|
||
|
||
```typescript
|
||
// src/blocks/ScriptSection.ts
|
||
|
||
import { Block } from 'payload/types'
|
||
|
||
export const ScriptSectionBlock: Block = {
|
||
slug: 'scriptSection',
|
||
labels: {
|
||
singular: 'Script Section',
|
||
plural: 'Script Sections',
|
||
},
|
||
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: {
|
||
description: 'Art der Script-Section',
|
||
},
|
||
},
|
||
{
|
||
name: 'sectionTitle',
|
||
type: 'text',
|
||
label: 'Section-Titel',
|
||
admin: {
|
||
placeholder: 'z.B. "TEIL 1: OUTFIT-LOGIK"',
|
||
condition: (data, siblingData) =>
|
||
siblingData?.sectionType === 'content_part',
|
||
},
|
||
},
|
||
{
|
||
name: 'duration',
|
||
type: 'text',
|
||
label: 'Dauer',
|
||
admin: {
|
||
placeholder: 'z.B. "2-3 Min" oder "45-60 Sek"',
|
||
width: '25%',
|
||
},
|
||
},
|
||
{
|
||
name: 'spokenText',
|
||
type: 'richText',
|
||
label: 'Gesprochener Text',
|
||
required: true,
|
||
admin: {
|
||
description: 'Der Text, den Caroline spricht',
|
||
},
|
||
},
|
||
{
|
||
name: 'bRollInstructions',
|
||
type: 'array',
|
||
label: 'B-Roll Anweisungen',
|
||
labels: {
|
||
singular: 'B-Roll',
|
||
plural: 'B-Roll Anweisungen',
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'instruction',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
placeholder: 'z.B. "Outfit zeigen", "Kleiderschrank-Aufnahme"',
|
||
},
|
||
},
|
||
{
|
||
name: 'timestamp',
|
||
type: 'text',
|
||
admin: {
|
||
placeholder: 'Optional: "bei 0:45"',
|
||
width: '30%',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'textOverlays',
|
||
type: 'array',
|
||
label: 'Text-Overlays',
|
||
labels: {
|
||
singular: 'Overlay',
|
||
plural: 'Text-Overlays',
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'text',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
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' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'visualNotes',
|
||
type: 'textarea',
|
||
label: 'Visuelle Notizen',
|
||
admin: {
|
||
placeholder: 'Zusätzliche Anweisungen für Schnitt/Grafik',
|
||
rows: 2,
|
||
},
|
||
},
|
||
],
|
||
}
|
||
```
|
||
|
||
### 1.3 Script Editor Field in YouTube Content
|
||
|
||
```typescript
|
||
// In YouTubeContent.ts
|
||
|
||
{
|
||
name: 'script',
|
||
type: 'blocks',
|
||
label: 'Script',
|
||
labels: {
|
||
singular: 'Section',
|
||
plural: 'Script Sections',
|
||
},
|
||
blocks: [ScriptSectionBlock],
|
||
admin: {
|
||
description: 'Strukturiertes Video-Script mit Sections',
|
||
},
|
||
}
|
||
```
|
||
|
||
### 1.4 Script Templates
|
||
|
||
```typescript
|
||
// src/collections/ScriptTemplates.ts
|
||
|
||
export const ScriptTemplates: CollectionConfig = {
|
||
slug: 'yt-script-templates',
|
||
labels: {
|
||
singular: 'Script Template',
|
||
plural: 'Script Templates',
|
||
},
|
||
admin: {
|
||
group: 'YouTube',
|
||
useAsTitle: 'name',
|
||
defaultColumns: ['name', 'series', 'format', 'updatedAt'],
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'name',
|
||
type: 'text',
|
||
required: true,
|
||
localized: true,
|
||
// z.B. "GRFI Longform Template", "M2M Short Template"
|
||
},
|
||
{
|
||
name: 'series',
|
||
type: 'text',
|
||
required: true,
|
||
// z.B. "GRFI", "M2M", "SPARK"
|
||
},
|
||
{
|
||
name: 'format',
|
||
type: 'select',
|
||
required: true,
|
||
options: [
|
||
{ label: 'Short (45-60s)', value: 'short' },
|
||
{ label: 'Longform (8-16 Min)', value: 'longform' },
|
||
],
|
||
},
|
||
{
|
||
name: 'channel',
|
||
type: 'relationship',
|
||
relationTo: 'youtube-channels',
|
||
required: true,
|
||
},
|
||
{
|
||
name: 'templateSections',
|
||
type: 'blocks',
|
||
blocks: [ScriptSectionBlock],
|
||
// Vorgefüllte Section-Struktur
|
||
},
|
||
{
|
||
name: 'notes',
|
||
type: 'textarea',
|
||
localized: true,
|
||
admin: {
|
||
description: 'Hinweise zur Verwendung dieses Templates',
|
||
},
|
||
},
|
||
],
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Kalenderansichten in Payload Admin
|
||
|
||
### 2.1 Custom Admin Views Struktur
|
||
|
||
```
|
||
src/
|
||
├── app/(payload)/admin/
|
||
│ ├── [[...segments]]/
|
||
│ │ └── page.tsx # Standard Payload Admin
|
||
│ └── views/
|
||
│ └── youtube/
|
||
│ ├── content-calendar/
|
||
│ │ └── page.tsx # Content/Production Calendar
|
||
│ ├── posting-calendar/
|
||
│ │ └── page.tsx # Publishing Calendar
|
||
│ └── batch-overview/
|
||
│ └── page.tsx # Batch Dashboard
|
||
```
|
||
|
||
### 2.2 Content Calendar View
|
||
|
||
```typescript
|
||
// src/app/(payload)/admin/views/youtube/content-calendar/page.tsx
|
||
|
||
'use client'
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import { useConfig } from '@payloadcms/ui'
|
||
import { format, startOfMonth, endOfMonth, eachDayOfInterval,
|
||
startOfWeek, endOfWeek, isSameMonth, isSameDay } from 'date-fns'
|
||
import { de } from 'date-fns/locale'
|
||
|
||
interface CalendarVideo {
|
||
id: string
|
||
title: string
|
||
format: 'short' | 'longform'
|
||
series: string
|
||
status: string
|
||
productionDate?: string
|
||
scheduledPublishDate?: string
|
||
channel: {
|
||
name: string
|
||
slug: string
|
||
}
|
||
}
|
||
|
||
export default function ContentCalendarView() {
|
||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||
const [videos, setVideos] = useState<CalendarVideo[]>([])
|
||
const [selectedChannel, setSelectedChannel] = useState<string>('all')
|
||
const [viewType, setViewType] = useState<'production' | 'publishing'>('production')
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
fetchVideos()
|
||
}, [currentMonth, selectedChannel])
|
||
|
||
const fetchVideos = 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()}`
|
||
|
||
if (selectedChannel !== 'all') {
|
||
query += `&where[channel][equals]=${selectedChannel}`
|
||
}
|
||
|
||
const res = await fetch(`/api/youtube-content?${query}&depth=1&limit=100`)
|
||
const data = await res.json()
|
||
setVideos(data.docs)
|
||
setLoading(false)
|
||
}
|
||
|
||
const renderCalendar = () => {
|
||
const monthStart = startOfMonth(currentMonth)
|
||
const monthEnd = endOfMonth(currentMonth)
|
||
const calendarStart = startOfWeek(monthStart, { locale: de })
|
||
const calendarEnd = endOfWeek(monthEnd, { locale: de })
|
||
|
||
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
||
|
||
return (
|
||
<div className="calendar-grid">
|
||
{/* Header */}
|
||
<div className="calendar-header">
|
||
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map(day => (
|
||
<div key={day} className="calendar-header-cell">{day}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Days */}
|
||
<div className="calendar-body">
|
||
{days.map(day => {
|
||
const dateField = viewType === 'production' ? 'productionDate' : 'scheduledPublishDate'
|
||
const dayVideos = videos.filter(v =>
|
||
v[dateField] && isSameDay(new Date(v[dateField]), day)
|
||
)
|
||
|
||
return (
|
||
<div
|
||
key={day.toISOString()}
|
||
className={`calendar-cell ${!isSameMonth(day, currentMonth) ? 'outside-month' : ''}`}
|
||
>
|
||
<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} ${video.status}`}
|
||
>
|
||
<span className="video-series">{video.series}</span>
|
||
<span className="video-title">{video.title}</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 onClick={() => setCurrentMonth(prev =>
|
||
new Date(prev.getFullYear(), prev.getMonth() - 1)
|
||
)}>
|
||
← Vorheriger
|
||
</button>
|
||
<h2>{format(currentMonth, 'MMMM yyyy', { locale: de })}</h2>
|
||
<button onClick={() => setCurrentMonth(prev =>
|
||
new Date(prev.getFullYear(), prev.getMonth() + 1)
|
||
)}>
|
||
Nächster →
|
||
</button>
|
||
</div>
|
||
|
||
<div className="filters">
|
||
<select
|
||
value={viewType}
|
||
onChange={e => setViewType(e.target.value as any)}
|
||
>
|
||
<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>
|
||
<option value="blogwoman">BlogWoman</option>
|
||
<option value="corporate-de">Corporate DE</option>
|
||
<option value="business-en">Business EN</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="calendar-legend">
|
||
<span className="legend-item short">Short</span>
|
||
<span className="legend-item longform">Longform</span>
|
||
<span className="legend-item status-idea">Idee</span>
|
||
<span className="legend-item status-production">In Produktion</span>
|
||
<span className="legend-item status-ready">Fertig</span>
|
||
<span className="legend-item status-published">Veröffentlicht</span>
|
||
</div>
|
||
</header>
|
||
|
||
{loading ? (
|
||
<div className="loading">Lade Kalender...</div>
|
||
) : (
|
||
renderCalendar()
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 2.3 Calendar Styles
|
||
|
||
```css
|
||
/* src/app/(payload)/admin/views/youtube/calendar.css */
|
||
|
||
.content-calendar-view {
|
||
padding: 20px;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.calendar-controls {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.controls-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.month-navigation {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.month-navigation button {
|
||
padding: 8px 16px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background: white;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.month-navigation h2 {
|
||
min-width: 200px;
|
||
text-align: center;
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.filters select {
|
||
padding: 8px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.calendar-legend {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 16px;
|
||
padding: 12px;
|
||
background: #f5f5f5;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.legend-item::before {
|
||
content: '';
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.legend-item.short::before { background: #3B82F6; }
|
||
.legend-item.longform::before { background: #8B5CF6; }
|
||
.legend-item.status-idea::before { background: #9CA3AF; }
|
||
.legend-item.status-production::before { background: #F59E0B; }
|
||
.legend-item.status-ready::before { background: #10B981; }
|
||
.legend-item.status-published::before { background: #059669; }
|
||
|
||
/* Calendar Grid */
|
||
.calendar-grid {
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.calendar-header {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
background: #f5f5f5;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
|
||
.calendar-header-cell {
|
||
padding: 12px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.calendar-body {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
}
|
||
|
||
.calendar-cell {
|
||
min-height: 120px;
|
||
border-right: 1px solid #eee;
|
||
border-bottom: 1px solid #eee;
|
||
padding: 8px;
|
||
}
|
||
|
||
.calendar-cell:nth-child(7n) {
|
||
border-right: none;
|
||
}
|
||
|
||
.calendar-cell.outside-month {
|
||
background: #fafafa;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.calendar-date {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.calendar-videos {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.calendar-video {
|
||
display: block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
text-decoration: none;
|
||
color: white;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.calendar-video.short {
|
||
background: #3B82F6;
|
||
}
|
||
|
||
.calendar-video.longform {
|
||
background: #8B5CF6;
|
||
}
|
||
|
||
.calendar-video .video-series {
|
||
font-weight: 600;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.calendar-video:hover {
|
||
opacity: 0.9;
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
/* Status Colors als Border */
|
||
.calendar-video.idea { border-left: 3px solid #9CA3AF; }
|
||
.calendar-video.script_draft { border-left: 3px solid #F59E0B; }
|
||
.calendar-video.in_production { border-left: 3px solid #F59E0B; }
|
||
.calendar-video.ready { border-left: 3px solid #10B981; }
|
||
.calendar-video.published { border-left: 3px solid #059669; }
|
||
```
|
||
|
||
### 2.4 Admin Navigation erweitern
|
||
|
||
```typescript
|
||
// payload.config.ts - Admin Navigation
|
||
|
||
export default buildConfig({
|
||
admin: {
|
||
components: {
|
||
// Custom Navigation mit YouTube Views
|
||
Nav: '/components/CustomNav',
|
||
},
|
||
},
|
||
// ...
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// src/components/CustomNav.tsx
|
||
|
||
'use client'
|
||
|
||
import { NavGroup, Nav as DefaultNav } from '@payloadcms/ui'
|
||
import Link from 'next/link'
|
||
|
||
export const CustomNav = () => {
|
||
return (
|
||
<nav>
|
||
<DefaultNav />
|
||
|
||
{/* YouTube Views */}
|
||
<NavGroup label="YouTube Views">
|
||
<Link href="/admin/views/youtube/content-calendar">
|
||
📅 Content Calendar
|
||
</Link>
|
||
<Link href="/admin/views/youtube/posting-calendar">
|
||
📤 Posting Calendar
|
||
</Link>
|
||
<Link href="/admin/views/youtube/batch-overview">
|
||
📦 Batch Overview
|
||
</Link>
|
||
<Link href="/admin/views/youtube/monthly-goals">
|
||
🎯 Monthly Goals
|
||
</Link>
|
||
</NavGroup>
|
||
</nav>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Detaillierte Batch-Planung
|
||
|
||
### 3.1 Batch Collection
|
||
|
||
```typescript
|
||
// src/collections/YtBatches.ts
|
||
|
||
import { CollectionConfig } from 'payload/types'
|
||
import { isYouTubeManager, isYouTubeCreatorOrAbove } from '../lib/youtubeAccess'
|
||
|
||
export const YtBatches: CollectionConfig = {
|
||
slug: 'yt-batches',
|
||
labels: {
|
||
singular: 'Production Batch',
|
||
plural: 'Production Batches',
|
||
},
|
||
admin: {
|
||
group: 'YouTube',
|
||
useAsTitle: 'name',
|
||
defaultColumns: ['name', 'channel', 'status', 'productionStart', 'progress'],
|
||
listSearchableFields: ['name'],
|
||
},
|
||
access: {
|
||
read: isYouTubeCreatorOrAbove,
|
||
create: isYouTubeManager,
|
||
update: isYouTubeCreatorOrAbove,
|
||
delete: isYouTubeManager,
|
||
},
|
||
fields: [
|
||
// === BASIC INFO ===
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'name',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
placeholder: 'z.B. "Januar Woche 1" oder "Batch 1"',
|
||
width: '50%',
|
||
},
|
||
},
|
||
{
|
||
name: 'channel',
|
||
type: 'relationship',
|
||
relationTo: 'youtube-channels',
|
||
required: true,
|
||
admin: {
|
||
width: '50%',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// === ZEITRAUM ===
|
||
{
|
||
name: 'productionPeriod',
|
||
type: 'group',
|
||
label: 'Produktionszeitraum',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'start',
|
||
type: 'date',
|
||
required: true,
|
||
label: 'Start',
|
||
admin: {
|
||
width: '50%',
|
||
date: {
|
||
pickerAppearance: 'dayOnly',
|
||
displayFormat: 'dd.MM.yyyy',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'end',
|
||
type: 'date',
|
||
required: true,
|
||
label: 'Ende',
|
||
admin: {
|
||
width: '50%',
|
||
date: {
|
||
pickerAppearance: 'dayOnly',
|
||
displayFormat: 'dd.MM.yyyy',
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'shootDays',
|
||
type: 'array',
|
||
label: 'Drehtage',
|
||
labels: {
|
||
singular: 'Drehtag',
|
||
plural: 'Drehtage',
|
||
},
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'date',
|
||
type: 'date',
|
||
required: true,
|
||
admin: {
|
||
width: '30%',
|
||
date: {
|
||
pickerAppearance: 'dayOnly',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'location',
|
||
type: 'select',
|
||
options: [
|
||
{ label: '🏠 Home Studio', value: 'home' },
|
||
{ label: '🏢 Office', value: 'office' },
|
||
{ label: '🚗 Unterwegs', value: 'mobile' },
|
||
{ label: '📍 Extern', value: 'external' },
|
||
],
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'duration',
|
||
type: 'select',
|
||
options: [
|
||
{ label: '2 Stunden', value: '2h' },
|
||
{ label: '4 Stunden (Halbtag)', value: '4h' },
|
||
{ label: '8 Stunden (Ganztag)', value: '8h' },
|
||
],
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'notes',
|
||
type: 'text',
|
||
admin: {
|
||
width: '20%',
|
||
placeholder: 'Notizen',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === CONTENT TARGETS ===
|
||
{
|
||
name: 'targets',
|
||
type: 'group',
|
||
label: 'Content-Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'shortsTarget',
|
||
type: 'number',
|
||
label: 'Shorts (Ziel)',
|
||
required: true,
|
||
defaultValue: 7,
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'longformsTarget',
|
||
type: 'number',
|
||
label: 'Longforms (Ziel)',
|
||
required: true,
|
||
defaultValue: 3,
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'totalTarget',
|
||
type: 'number',
|
||
label: 'Gesamt (Ziel)',
|
||
admin: {
|
||
width: '25%',
|
||
readOnly: true,
|
||
description: 'Automatisch berechnet',
|
||
},
|
||
hooks: {
|
||
beforeChange: [
|
||
({ siblingData }) => {
|
||
return (siblingData.shortsTarget || 0) + (siblingData.longformsTarget || 0)
|
||
},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
name: 'bufferDays',
|
||
type: 'number',
|
||
label: 'Puffer (Tage)',
|
||
defaultValue: 3,
|
||
admin: {
|
||
width: '25%',
|
||
description: 'Tage zwischen Produktion und Publish',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === SERIEN-VERTEILUNG ===
|
||
{
|
||
name: 'seriesDistribution',
|
||
type: 'array',
|
||
label: 'Serien-Verteilung',
|
||
admin: {
|
||
description: 'Welche Serien in diesem Batch produziert werden',
|
||
},
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'series',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
width: '30%',
|
||
placeholder: 'z.B. GRFI',
|
||
},
|
||
},
|
||
{
|
||
name: 'shortsCount',
|
||
type: 'number',
|
||
label: 'Shorts',
|
||
defaultValue: 0,
|
||
admin: { width: '20%' },
|
||
},
|
||
{
|
||
name: 'longformsCount',
|
||
type: 'number',
|
||
label: 'Longforms',
|
||
defaultValue: 0,
|
||
admin: { width: '20%' },
|
||
},
|
||
{
|
||
name: 'priority',
|
||
type: 'select',
|
||
options: [
|
||
{ label: '🔴 Hoch', value: 'high' },
|
||
{ label: '🟡 Normal', value: 'normal' },
|
||
{ label: '🟢 Niedrig', value: 'low' },
|
||
],
|
||
defaultValue: 'normal',
|
||
admin: { width: '30%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === STATUS & PROGRESS ===
|
||
{
|
||
name: 'status',
|
||
type: 'select',
|
||
required: true,
|
||
defaultValue: 'planning',
|
||
options: [
|
||
{ label: '📝 Planung', value: 'planning' },
|
||
{ label: '✍️ Scripts', value: 'scripting' },
|
||
{ label: '🎬 Produktion', value: 'production' },
|
||
{ label: '✂️ Schnitt', value: 'editing' },
|
||
{ label: '✅ Review', value: 'review' },
|
||
{ label: '📤 Upload-Ready', value: 'ready' },
|
||
{ label: '🎉 Veröffentlicht', value: 'published' },
|
||
],
|
||
admin: {
|
||
position: 'sidebar',
|
||
},
|
||
},
|
||
|
||
// Virtuelle Felder für Progress (berechnet via Hook)
|
||
{
|
||
name: 'progress',
|
||
type: 'group',
|
||
label: 'Fortschritt',
|
||
admin: {
|
||
position: 'sidebar',
|
||
readOnly: true,
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'shortsCompleted',
|
||
type: 'number',
|
||
label: 'Shorts fertig',
|
||
admin: { readOnly: true },
|
||
},
|
||
{
|
||
name: 'longformsCompleted',
|
||
type: 'number',
|
||
label: 'Longforms fertig',
|
||
admin: { readOnly: true },
|
||
},
|
||
{
|
||
name: 'percentage',
|
||
type: 'number',
|
||
label: 'Gesamt %',
|
||
admin: { readOnly: true },
|
||
},
|
||
],
|
||
},
|
||
|
||
// === TEAM & RESOURCES ===
|
||
{
|
||
name: 'team',
|
||
type: 'group',
|
||
label: '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%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === NOTES ===
|
||
{
|
||
name: 'notes',
|
||
type: 'textarea',
|
||
label: 'Notizen',
|
||
admin: {
|
||
rows: 4,
|
||
},
|
||
},
|
||
],
|
||
|
||
// === HOOKS ===
|
||
hooks: {
|
||
afterRead: [
|
||
// Progress aus verknüpften Videos berechnen
|
||
async ({ doc, req }) => {
|
||
if (!doc?.id) return doc
|
||
|
||
const videos = await req.payload.find({
|
||
collection: 'youtube-content',
|
||
where: {
|
||
productionBatch: { equals: doc.id },
|
||
},
|
||
limit: 100,
|
||
})
|
||
|
||
const shorts = videos.docs.filter(v => v.format === 'short')
|
||
const longforms = videos.docs.filter(v => v.format === 'longform')
|
||
|
||
const completedStatuses = ['approved', 'upload_scheduled', 'published', 'tracked']
|
||
|
||
const shortsCompleted = shorts.filter(v =>
|
||
completedStatuses.includes(v.status)
|
||
).length
|
||
|
||
const longformsCompleted = longforms.filter(v =>
|
||
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,
|
||
},
|
||
}
|
||
},
|
||
],
|
||
},
|
||
}
|
||
```
|
||
|
||
### 3.2 Batch Overview Dashboard
|
||
|
||
```typescript
|
||
// src/app/(payload)/admin/views/youtube/batch-overview/page.tsx
|
||
|
||
'use client'
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import { format } from 'date-fns'
|
||
import { de } from 'date-fns/locale'
|
||
|
||
interface Batch {
|
||
id: string
|
||
name: string
|
||
channel: { name: string; slug: string }
|
||
status: string
|
||
productionPeriod: {
|
||
start: string
|
||
end: string
|
||
}
|
||
targets: {
|
||
shortsTarget: number
|
||
longformsTarget: number
|
||
totalTarget: number
|
||
}
|
||
progress: {
|
||
shortsCompleted: number
|
||
longformsCompleted: number
|
||
percentage: number
|
||
}
|
||
}
|
||
|
||
export default function BatchOverviewView() {
|
||
const [batches, setBatches] = useState<Batch[]>([])
|
||
const [selectedChannel, setSelectedChannel] = useState('all')
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
fetchBatches()
|
||
}, [selectedChannel])
|
||
|
||
const fetchBatches = async () => {
|
||
setLoading(true)
|
||
let query = 'depth=1&limit=50&sort=-productionPeriod.start'
|
||
|
||
if (selectedChannel !== 'all') {
|
||
query += `&where[channel][equals]=${selectedChannel}`
|
||
}
|
||
|
||
const res = await fetch(`/api/yt-batches?${query}`)
|
||
const data = await res.json()
|
||
setBatches(data.docs)
|
||
setLoading(false)
|
||
}
|
||
|
||
const getStatusColor = (status: string) => {
|
||
const colors: Record<string, string> = {
|
||
planning: '#9CA3AF',
|
||
scripting: '#60A5FA',
|
||
production: '#FBBF24',
|
||
editing: '#F97316',
|
||
review: '#A78BFA',
|
||
ready: '#34D399',
|
||
published: '#10B981',
|
||
}
|
||
return colors[status] || '#9CA3AF'
|
||
}
|
||
|
||
const getStatusLabel = (status: string) => {
|
||
const labels: Record<string, string> = {
|
||
planning: '📝 Planung',
|
||
scripting: '✍️ Scripts',
|
||
production: '🎬 Produktion',
|
||
editing: '✂️ Schnitt',
|
||
review: '✅ Review',
|
||
ready: '📤 Ready',
|
||
published: '🎉 Published',
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
return (
|
||
<div className="batch-overview">
|
||
<header>
|
||
<h1>📦 Batch Overview</h1>
|
||
|
||
<div className="controls">
|
||
<select
|
||
value={selectedChannel}
|
||
onChange={e => setSelectedChannel(e.target.value)}
|
||
>
|
||
<option value="all">Alle Kanäle</option>
|
||
<option value="blogwoman">BlogWoman</option>
|
||
<option value="corporate-de">Corporate DE</option>
|
||
<option value="business-en">Business EN</option>
|
||
</select>
|
||
|
||
<a href="/admin/collections/yt-batches/create" className="btn-primary">
|
||
+ Neuer Batch
|
||
</a>
|
||
</div>
|
||
</header>
|
||
|
||
{loading ? (
|
||
<div className="loading">Lade Batches...</div>
|
||
) : (
|
||
<div className="batch-grid">
|
||
{batches.map(batch => (
|
||
<a
|
||
key={batch.id}
|
||
href={`/admin/collections/yt-batches/${batch.id}`}
|
||
className="batch-card"
|
||
>
|
||
<div className="batch-header">
|
||
<h3>{batch.name}</h3>
|
||
<span
|
||
className="status-badge"
|
||
style={{ backgroundColor: getStatusColor(batch.status) }}
|
||
>
|
||
{getStatusLabel(batch.status)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="batch-channel">
|
||
{batch.channel?.name}
|
||
</div>
|
||
|
||
<div className="batch-dates">
|
||
📅 {format(new Date(batch.productionPeriod.start), 'dd.MM.', { locale: de })}
|
||
- {format(new Date(batch.productionPeriod.end), 'dd.MM.yyyy', { locale: de })}
|
||
</div>
|
||
|
||
<div className="batch-progress">
|
||
<div className="progress-bar">
|
||
<div
|
||
className="progress-fill"
|
||
style={{ width: `${batch.progress?.percentage || 0}%` }}
|
||
/>
|
||
</div>
|
||
<span className="progress-text">
|
||
{batch.progress?.percentage || 0}%
|
||
</span>
|
||
</div>
|
||
|
||
<div className="batch-counts">
|
||
<div className="count">
|
||
<span className="count-value">
|
||
{batch.progress?.shortsCompleted || 0}/{batch.targets?.shortsTarget || 0}
|
||
</span>
|
||
<span className="count-label">Shorts</span>
|
||
</div>
|
||
<div className="count">
|
||
<span className="count-value">
|
||
{batch.progress?.longformsCompleted || 0}/{batch.targets?.longformsTarget || 0}
|
||
</span>
|
||
<span className="count-label">Longforms</span>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Monthly Goals Collection
|
||
|
||
```typescript
|
||
// src/collections/YtMonthlyGoals.ts
|
||
|
||
import { CollectionConfig } from 'payload/types'
|
||
|
||
export const YtMonthlyGoals: CollectionConfig = {
|
||
slug: 'yt-monthly-goals',
|
||
labels: {
|
||
singular: 'Monthly Goal',
|
||
plural: 'Monthly Goals',
|
||
},
|
||
admin: {
|
||
group: 'YouTube',
|
||
useAsTitle: 'displayTitle',
|
||
defaultColumns: ['channel', 'month', 'status', 'updatedAt'],
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'channel',
|
||
type: 'relationship',
|
||
relationTo: 'youtube-channels',
|
||
required: true,
|
||
},
|
||
{
|
||
name: 'month',
|
||
type: 'date',
|
||
required: true,
|
||
admin: {
|
||
date: {
|
||
pickerAppearance: 'monthOnly',
|
||
displayFormat: 'MMMM yyyy',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'displayTitle',
|
||
type: 'text',
|
||
admin: {
|
||
hidden: true,
|
||
},
|
||
hooks: {
|
||
beforeChange: [
|
||
async ({ siblingData, req }) => {
|
||
if (siblingData.channel && siblingData.month) {
|
||
const channel = await req.payload.findByID({
|
||
collection: 'youtube-channels',
|
||
id: siblingData.channel,
|
||
})
|
||
const monthStr = new Date(siblingData.month).toLocaleDateString('de-DE', {
|
||
month: 'long',
|
||
year: 'numeric',
|
||
})
|
||
return `${channel?.name} - ${monthStr}`
|
||
}
|
||
return 'Neues Monatsziel'
|
||
},
|
||
],
|
||
},
|
||
},
|
||
|
||
// === CONTENT GOALS ===
|
||
{
|
||
name: 'contentGoals',
|
||
type: 'group',
|
||
label: 'Content-Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'longformsTarget',
|
||
type: 'number',
|
||
label: 'Longforms',
|
||
defaultValue: 12,
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'longformsCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
defaultValue: 0,
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'shortsTarget',
|
||
type: 'number',
|
||
label: 'Shorts',
|
||
defaultValue: 28,
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'shortsCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
defaultValue: 0,
|
||
admin: { width: '25%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === AUDIENCE GOALS ===
|
||
{
|
||
name: 'audienceGoals',
|
||
type: 'group',
|
||
label: 'Audience-Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'subscribersTarget',
|
||
type: 'number',
|
||
label: 'Neue Abos (Ziel)',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'subscribersCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'viewsTarget',
|
||
type: 'number',
|
||
label: 'Views (Ziel)',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'viewsCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === ENGAGEMENT GOALS ===
|
||
{
|
||
name: 'engagementGoals',
|
||
type: 'group',
|
||
label: 'Engagement-Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'avgCtrTarget',
|
||
type: 'text',
|
||
label: 'Ø CTR (Ziel)',
|
||
admin: {
|
||
width: '25%',
|
||
placeholder: 'z.B. ">4%"',
|
||
},
|
||
},
|
||
{
|
||
name: 'avgCtrCurrent',
|
||
type: 'text',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'avgRetentionTarget',
|
||
type: 'text',
|
||
label: 'Ø Retention (Ziel)',
|
||
admin: {
|
||
width: '25%',
|
||
placeholder: 'z.B. ">50%"',
|
||
},
|
||
},
|
||
{
|
||
name: 'avgRetentionCurrent',
|
||
type: 'text',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === BUSINESS GOALS ===
|
||
{
|
||
name: 'businessGoals',
|
||
type: 'group',
|
||
label: 'Business-Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'newsletterSignupsTarget',
|
||
type: 'number',
|
||
label: 'Newsletter-Anmeldungen',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'newsletterSignupsCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'affiliateClicksTarget',
|
||
type: 'number',
|
||
label: 'Affiliate-Klicks',
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'affiliateClicksCurrent',
|
||
type: 'number',
|
||
label: 'Aktuell',
|
||
admin: { width: '25%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === CUSTOM GOALS ===
|
||
{
|
||
name: 'customGoals',
|
||
type: 'array',
|
||
label: 'Weitere Ziele',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'metric',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
width: '40%',
|
||
placeholder: 'z.B. "SPARK Videos"',
|
||
},
|
||
},
|
||
{
|
||
name: 'target',
|
||
type: 'text',
|
||
required: true,
|
||
admin: {
|
||
width: '20%',
|
||
placeholder: 'Ziel',
|
||
},
|
||
},
|
||
{
|
||
name: 'current',
|
||
type: 'text',
|
||
admin: {
|
||
width: '20%',
|
||
placeholder: 'Aktuell',
|
||
},
|
||
},
|
||
{
|
||
name: 'status',
|
||
type: 'select',
|
||
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: 'Notizen / Learnings',
|
||
},
|
||
],
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Erweiterte YouTube Content Collection
|
||
|
||
```typescript
|
||
// Erweiterungen für YouTubeContent.ts
|
||
|
||
// Neue Felder hinzufügen:
|
||
|
||
// === PRODUKTION ===
|
||
{
|
||
name: 'productionBatch',
|
||
type: 'relationship',
|
||
relationTo: 'yt-batches',
|
||
label: 'Produktions-Batch',
|
||
admin: {
|
||
position: 'sidebar',
|
||
},
|
||
},
|
||
{
|
||
name: 'productionWeek',
|
||
type: 'number',
|
||
label: 'Produktionswoche',
|
||
min: 1,
|
||
max: 52,
|
||
admin: {
|
||
position: 'sidebar',
|
||
},
|
||
},
|
||
{
|
||
name: 'calendarWeek',
|
||
type: 'number',
|
||
label: 'Kalenderwoche',
|
||
min: 1,
|
||
max: 52,
|
||
admin: {
|
||
position: 'sidebar',
|
||
},
|
||
},
|
||
{
|
||
name: 'productionDate',
|
||
type: 'date',
|
||
label: 'Produktionsdatum',
|
||
admin: {
|
||
date: {
|
||
pickerAppearance: 'dayOnly',
|
||
displayFormat: 'dd.MM.yyyy',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'targetDuration',
|
||
type: 'text',
|
||
label: 'Ziel-Dauer',
|
||
admin: {
|
||
placeholder: 'z.B. "8-12 Min" oder "45-58s"',
|
||
},
|
||
},
|
||
{
|
||
name: 'bRollNotes',
|
||
type: 'textarea',
|
||
label: 'B-Roll / Setting Notizen',
|
||
admin: {
|
||
rows: 2,
|
||
},
|
||
},
|
||
|
||
// === POSTING ===
|
||
{
|
||
name: 'publishTime',
|
||
type: 'text',
|
||
label: 'Posting-Uhrzeit',
|
||
admin: {
|
||
placeholder: 'z.B. "07:00" oder "17:00"',
|
||
},
|
||
},
|
||
{
|
||
name: 'thumbnailText',
|
||
type: 'text',
|
||
label: 'Thumbnail-Text',
|
||
admin: {
|
||
placeholder: 'z.B. "BOARD READY | 7 MIN"',
|
||
},
|
||
},
|
||
{
|
||
name: 'ctaType',
|
||
type: 'select',
|
||
label: 'CTA-Typ',
|
||
options: [
|
||
{ label: 'Link in Bio', value: 'link_in_bio' },
|
||
{ label: 'Newsletter', value: 'newsletter' },
|
||
{ label: 'Longform verlinken', value: 'longform_link' },
|
||
{ label: 'Benutzerdefiniert', value: 'custom' },
|
||
],
|
||
},
|
||
{
|
||
name: 'ctaDetail',
|
||
type: 'text',
|
||
label: 'CTA-Detail',
|
||
admin: {
|
||
placeholder: 'z.B. "GRFI-Checkliste" oder "CPW-Rechner"',
|
||
condition: (data) => data?.ctaType,
|
||
},
|
||
},
|
||
|
||
// === SCRIPT (Block-basiert) ===
|
||
{
|
||
name: 'script',
|
||
type: 'blocks',
|
||
label: 'Script',
|
||
labels: {
|
||
singular: 'Section',
|
||
plural: 'Script Sections',
|
||
},
|
||
blocks: [ScriptSectionBlock],
|
||
},
|
||
|
||
// === UPLOAD CHECKLIST ===
|
||
{
|
||
name: 'uploadChecklist',
|
||
type: 'array',
|
||
label: 'Upload-Checkliste',
|
||
admin: {
|
||
condition: (data) => ['approved', 'upload_scheduled', 'published'].includes(data?.status),
|
||
},
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'step',
|
||
type: 'text',
|
||
required: true,
|
||
admin: { width: '40%' },
|
||
},
|
||
{
|
||
name: 'completed',
|
||
type: 'checkbox',
|
||
label: '✓',
|
||
admin: { width: '10%' },
|
||
},
|
||
{
|
||
name: 'completedAt',
|
||
type: 'date',
|
||
admin: {
|
||
width: '25%',
|
||
readOnly: true,
|
||
},
|
||
},
|
||
{
|
||
name: 'completedBy',
|
||
type: 'relationship',
|
||
relationTo: 'users',
|
||
admin: {
|
||
width: '25%',
|
||
readOnly: true,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
|
||
// === DISCLAIMERS ===
|
||
{
|
||
name: 'disclaimers',
|
||
type: 'array',
|
||
label: 'Disclaimers',
|
||
fields: [
|
||
{
|
||
type: 'row',
|
||
fields: [
|
||
{
|
||
name: 'type',
|
||
type: 'select',
|
||
required: true,
|
||
options: [
|
||
{ label: '⚠️ Medizinisch', value: 'medical' },
|
||
{ label: '⚖️ Rechtlich', value: 'legal' },
|
||
{ label: '🔗 Affiliate', value: 'affiliate' },
|
||
{ label: '🤝 Sponsored', value: 'sponsored' },
|
||
],
|
||
admin: { width: '25%' },
|
||
},
|
||
{
|
||
name: 'text',
|
||
type: 'text',
|
||
admin: { width: '50%' },
|
||
},
|
||
{
|
||
name: 'placement',
|
||
type: 'select',
|
||
options: [
|
||
{ label: 'Gesprochen', value: 'spoken' },
|
||
{ label: 'Text-Overlay', value: 'overlay' },
|
||
{ label: 'Beschreibung', value: 'description' },
|
||
{ label: 'Überall', value: 'all' },
|
||
],
|
||
admin: { width: '25%' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Zusammenfassung der neuen Collections
|
||
|
||
| Collection | Slug | Zweck |
|
||
|------------|------|-------|
|
||
| YouTube Channels | `youtube-channels` | Kanäle (existiert) |
|
||
| YouTube Content | `youtube-content` | Videos + Script (erweitert) |
|
||
| YT Tasks | `yt-tasks` | Aufgaben (existiert) |
|
||
| YT Notifications | `yt-notifications` | Benachrichtigungen (existiert) |
|
||
| **YT Batches** | `yt-batches` | **NEU: Produktions-Batches** |
|
||
| **YT Monthly Goals** | `yt-monthly-goals` | **NEU: Monatsziele** |
|
||
| **YT Script Templates** | `yt-script-templates` | **NEU: Skript-Vorlagen** |
|
||
| **YT Checklist Templates** | `yt-checklist-templates` | **NEU: Upload-Checklisten** |
|
||
|
||
---
|
||
|
||
## 7. Custom Admin Views
|
||
|
||
| View | Pfad | Zweck |
|
||
|------|------|-------|
|
||
| Content Calendar | `/admin/views/youtube/content-calendar` | Produktions- & Publishing-Kalender |
|
||
| Batch Overview | `/admin/views/youtube/batch-overview` | Dashboard aller Batches |
|
||
| Monthly Goals | `/admin/views/youtube/monthly-goals` | KPI-Dashboard |
|
||
|
||
---
|
||
|
||
## 8. Migrations
|
||
|
||
```typescript
|
||
// src/migrations/20260112_200000_youtube_ops_v2.ts
|
||
|
||
import { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres'
|
||
|
||
export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||
// 1. Neue Enums
|
||
await payload.db.drizzle.execute(`
|
||
CREATE TYPE enum_yt_batches_status AS ENUM (
|
||
'planning', 'scripting', 'production', 'editing', 'review', 'ready', 'published'
|
||
);
|
||
|
||
CREATE TYPE enum_script_section_type AS ENUM (
|
||
'hook', 'intro_ident', 'context', 'content_part', 'summary', 'cta', 'outro', 'disclaimer'
|
||
);
|
||
|
||
CREATE TYPE enum_cta_type AS ENUM (
|
||
'link_in_bio', 'newsletter', 'longform_link', 'custom'
|
||
);
|
||
|
||
CREATE TYPE enum_disclaimer_type AS ENUM (
|
||
'medical', 'legal', 'affiliate', 'sponsored'
|
||
);
|
||
|
||
CREATE TYPE enum_disclaimer_placement AS ENUM (
|
||
'spoken', 'overlay', 'description', 'all'
|
||
);
|
||
`);
|
||
|
||
// 2. Neue Tabellen
|
||
// ... (vollständige Migration)
|
||
}
|
||
|
||
export async function down({ payload }: MigrateDownArgs): Promise<void> {
|
||
// Rollback
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Nächste Schritte
|
||
|
||
1. **Claude Code Integration Prompt erstellen** mit allen Collections, Blocks und Views
|
||
2. **Seed-Daten** mit BlogWoman Januar Content als Beispiel
|
||
3. **CSS Styles** für Calendar und Dashboard Views
|
||
4. **Lexical Custom Block** für Script Section Editor
|
||
|
||
Soll ich jetzt den vollständigen **Claude Code Integration Prompt** erstellen?
|