mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
feat(youtube): add FullCalendar content calendar component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
95079ec652
commit
5394f628e4
2 changed files with 310 additions and 0 deletions
96
src/components/admin/ContentCalendar.module.scss
Normal file
96
src/components/admin/ContentCalendar.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
214
src/components/admin/ContentCalendar.tsx
Normal file
214
src/components/admin/ContentCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue