feat(youtube): add FullCalendar content calendar component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 13:53:30 +00:00
parent 95079ec652
commit 5394f628e4
2 changed files with 310 additions and 0 deletions

View file

@ -0,0 +1,96 @@
.contentCalendar {
padding: var(--base);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--base);
flex-wrap: wrap;
gap: var(--base);
}
&__title {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-text);
margin: 0;
}
&__filters {
display: flex;
gap: calc(var(--base) / 2);
align-items: center;
}
&__select {
padding: calc(var(--base) / 4) calc(var(--base) / 2);
border: 1px solid var(--theme-elevation-150);
border-radius: 4px;
background: var(--theme-input-bg);
color: var(--theme-text);
font-size: 0.875rem;
}
&__calendar {
background: var(--theme-bg);
border-radius: 8px;
padding: var(--base);
border: 1px solid var(--theme-elevation-100);
}
&__conflict {
border: 2px solid #e74c3c !important;
animation: pulse 2s infinite;
}
&__legend {
display: flex;
gap: var(--base);
margin-top: var(--base);
flex-wrap: wrap;
}
&__legendItem {
display: flex;
align-items: center;
gap: calc(var(--base) / 4);
font-size: 0.8rem;
color: var(--theme-text);
}
&__legendColor {
width: 12px;
height: 12px;
border-radius: 2px;
}
&__conflicts {
margin-top: var(--base);
padding: var(--base);
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
h4 {
margin: 0 0 calc(var(--base) / 2) 0;
}
ul {
margin: 0;
padding-left: 1.5rem;
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}

View file

@ -0,0 +1,214 @@
'use client'
import type { DatesSetArg, EventClickArg, EventContentArg, EventDropArg } from '@fullcalendar/core'
import React, { useCallback, useEffect, useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import listPlugin from '@fullcalendar/list'
import timeGridPlugin from '@fullcalendar/timegrid'
import styles from './ContentCalendar.module.scss'
interface CalendarEvent {
id: string
title: string
start: string
color: string
extendedProps: {
status: string
contentType: string
channelId: number
channelName?: string
seriesName?: string
hasConflict: boolean
}
}
interface Conflict {
type: string
message: string
eventIds: number[]
severity: 'warning' | 'error'
}
interface Channel {
id: number
name: string
branding?: { primaryColor?: string }
}
const STATUS_ICONS: Record<string, string> = {
idea: '\u{1F4A1}',
script_draft: '\u{270F}\u{FE0F}',
script_review: '\u{1F50D}',
approved: '\u{2705}',
upload_scheduled: '\u{2B06}\u{FE0F}',
published: '\u{1F4FA}',
tracked: '\u{1F4CA}',
}
const DEFAULT_STATUS_ICON = '\u{1F3AC}'
const DEFAULT_CHANNEL_COLOR = '#3788d8'
export function ContentCalendar(): React.JSX.Element {
const [events, setEvents] = useState<CalendarEvent[]>([])
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState('all')
const [loading, setLoading] = useState(true)
const fetchEvents = useCallback(
async (start: string, end: string) => {
setLoading(true)
try {
const params = new URLSearchParams({ start, end })
if (selectedChannel !== 'all') {
params.set('channelId', selectedChannel)
}
const res = await fetch(`/api/youtube/calendar?${params}`, {
credentials: 'include',
})
const data = await res.json()
setEvents(data.events || [])
setConflicts(data.conflicts || [])
} catch (error) {
console.error('Failed to fetch calendar events:', error)
} finally {
setLoading(false)
}
},
[selectedChannel],
)
useEffect(() => {
fetch('/api/youtube-channels?limit=50&depth=0', { credentials: 'include' })
.then((r) => r.json())
.then((data) => setChannels(data.docs || []))
.catch(console.error)
}, [])
const handleDatesSet = useCallback(
(arg: DatesSetArg) => {
fetchEvents(arg.startStr, arg.endStr)
},
[fetchEvents],
)
const handleEventDrop = useCallback(async (info: EventDropArg) => {
const { id } = info.event
const newDate = info.event.start?.toISOString()
if (!newDate) {
info.revert()
return
}
try {
const res = await fetch('/api/youtube/calendar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ contentId: parseInt(id), newDate }),
})
if (!res.ok) {
const data = await res.json()
console.error('Reschedule failed:', data.error)
info.revert()
}
} catch {
info.revert()
}
}, [])
const handleEventClick = useCallback((info: EventClickArg) => {
window.location.href = `/admin/collections/youtube-content/${info.event.id}`
}, [])
const renderEventContent = useCallback((eventInfo: EventContentArg) => {
const { hasConflict, contentType, status } = eventInfo.event.extendedProps
const icon = STATUS_ICONS[status] || DEFAULT_STATUS_ICON
const typeLabel = contentType === 'short' ? 'S' : 'L'
return (
<div className={hasConflict ? styles.contentCalendar__conflict : ''}>
<span>{icon} </span>
<span>{typeLabel} </span>
<b>{eventInfo.event.title}</b>
</div>
)
}, [])
return (
<div className={styles.contentCalendar}>
<div className={styles.contentCalendar__header}>
<h1 className={styles.contentCalendar__title}>Content-Kalender</h1>
<div className={styles.contentCalendar__filters}>
<select
className={styles.contentCalendar__select}
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
>
<option value="all">Alle Kanäle</option>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>
{ch.name}
</option>
))}
</select>
</div>
</div>
<div className={styles.contentCalendar__calendar}>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
initialView="dayGridMonth"
locale="de"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek',
}}
events={events}
editable={true}
droppable={false}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
eventContent={renderEventContent}
datesSet={handleDatesSet}
height="auto"
firstDay={1}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
/>
</div>
{channels.length > 0 && (
<div className={styles.contentCalendar__legend}>
{channels.map((ch) => (
<div key={ch.id} className={styles.contentCalendar__legendItem}>
<div
className={styles.contentCalendar__legendColor}
style={{ background: ch.branding?.primaryColor || DEFAULT_CHANNEL_COLOR }}
/>
<span>{ch.name}</span>
</div>
))}
</div>
)}
{conflicts.length > 0 && (
<div className={styles.contentCalendar__conflicts}>
<h4>Konflikte ({conflicts.length})</h4>
<ul>
{conflicts.map((c, i) => (
<li key={i}>{c.message}</li>
))}
</ul>
</div>
)}
</div>
)
}