mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24: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