From 5394f628e4343d8658c545b68306202427b3b5b5 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:53:30 +0000 Subject: [PATCH] feat(youtube): add FullCalendar content calendar component Co-Authored-By: Claude Opus 4.6 --- .../admin/ContentCalendar.module.scss | 96 ++++++++ src/components/admin/ContentCalendar.tsx | 214 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/components/admin/ContentCalendar.module.scss create mode 100644 src/components/admin/ContentCalendar.tsx diff --git a/src/components/admin/ContentCalendar.module.scss b/src/components/admin/ContentCalendar.module.scss new file mode 100644 index 0000000..bd4d666 --- /dev/null +++ b/src/components/admin/ContentCalendar.module.scss @@ -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; + } +} diff --git a/src/components/admin/ContentCalendar.tsx b/src/components/admin/ContentCalendar.tsx new file mode 100644 index 0000000..114e0d0 --- /dev/null +++ b/src/components/admin/ContentCalendar.tsx @@ -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 = { + 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([]) + const [conflicts, setConflicts] = useState([]) + const [channels, setChannels] = useState([]) + 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 ( +
+ {icon} + {typeLabel} + {eventInfo.event.title} +
+ ) + }, []) + + return ( +
+
+

Content-Kalender

+
+ +
+
+ +
+ +
+ + {channels.length > 0 && ( +
+ {channels.map((ch) => ( +
+
+ {ch.name} +
+ ))} +
+ )} + + {conflicts.length > 0 && ( +
+

Konflikte ({conflicts.length})

+
    + {conflicts.map((c, i) => ( +
  • {c.message}
  • + ))} +
+
+ )} +
+ ) +}