o2o-infinith-demo/src/components/plan/ContentCalendar.tsx

419 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useState, useCallback } from 'react';
import { exportCalendarToICS } from '../../lib/calendarExport';
import {
VideoFilled,
FileTextFilled,
ShareFilled,
MegaphoneFilled,
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
MessageFilled,
CalendarFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import EditEntryModal from './EditEntryModal';
import type { CalendarData, ContentCategory, CalendarEntry } from '../../types/plan';
interface ContentCalendarProps {
data: CalendarData;
planId?: string;
onEntryUpdate?: (entryId: string, updates: Partial<CalendarEntry>) => void;
}
const contentTypeColors: Record<ContentCategory, { bg: string; text: string; entry: string; border: string; shadow: string }> = {
video: { bg: 'bg-[#F3F0FF]', text: 'text-[#6C5CE7]', entry: 'bg-[#EDE5FF] border-[#B8A8E8]', border: 'border-[#D5CDF5]', shadow: 'shadow-[2px_3px_8px_rgba(155,138,212,0.15)]' },
blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]', entry: 'bg-[#E2E5FF] border-[#A8B0E8]', border: 'border-[#C5CBF5]', shadow: 'shadow-[2px_3px_8px_rgba(122,132,212,0.15)]' },
social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', entry: 'bg-[#FFEED9] border-[#E8C896]', border: 'border-[#F5E0C5]', shadow: 'shadow-[2px_3px_8px_rgba(212,168,114,0.15)]' },
ad: { bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', entry: 'bg-[#FFE0E0] border-[#E8A8B4]', border: 'border-[#F5D5DC]', shadow: 'shadow-[2px_3px_8px_rgba(212,136,154,0.15)]' },
};
const contentTypeLabels: Record<ContentCategory, string> = {
video: 'Video',
blog: 'Blog',
social: 'Social',
ad: 'Ad',
};
const contentTypeIcons: Record<ContentCategory, typeof VideoFilled> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
ad: MegaphoneFilled,
};
// FilledIcon map for channel identification in calendar entries
const channelIconMap: Record<string, typeof VideoFilled> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
blog: FileTextFilled,
globe: GlobeFilled,
video: TiktokFilled,
star: GlobeFilled,
map: GlobeFilled,
messagesquare: MessageFilled,
};
// Semantic status colors (no raw green/red per design system)
const statusDotColors: Record<string, string> = {
draft: 'bg-slate-400',
approved: 'bg-[#9B8AD4]',
published: 'bg-[#7A84D4]',
};
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
export default function ContentCalendar({ data, planId, onEntryUpdate }: ContentCalendarProps) {
const [weeks, setWeeks] = useState(data.weeks);
const [editingEntry, setEditingEntry] = useState<CalendarEntry | null>(null);
const [creatingForWeek, setCreatingForWeek] = useState<number | null>(null);
const [filterType, setFilterType] = useState<ContentCategory | null>(null);
const [viewMode, setViewMode] = useState<'weekly' | 'monthly'>('weekly');
// DnD state: track which entry is being dragged and from which week
const [draggedEntry, setDraggedEntry] = useState<{ entry: CalendarEntry; weekNumber: number } | null>(null);
const [dropTargetDay, setDropTargetDay] = useState<{ weekNumber: number; dayIdx: number } | null>(null);
const handleEntryClick = useCallback((entry: CalendarEntry) => {
setCreatingForWeek(null);
setEditingEntry(entry);
}, []);
// Open modal in create mode for a given week
const handleAddEntry = useCallback((weekNumber: number) => {
setCreatingForWeek(weekNumber);
setEditingEntry({
id: '__new__',
dayOfWeek: 0,
channel: 'YouTube',
channelIcon: 'youtube',
contentType: 'video',
title: '',
status: 'draft',
});
}, []);
// Drag handlers — move an entry to a different day within the same week
const handleDragStart = useCallback((entry: CalendarEntry, weekNumber: number) => {
setDraggedEntry({ entry, weekNumber });
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, weekNumber: number, dayIdx: number) => {
e.preventDefault(); // required to allow drop
setDropTargetDay({ weekNumber, dayIdx });
}, []);
const handleDragLeave = useCallback(() => {
setDropTargetDay(null);
}, []);
const handleDrop = useCallback((weekNumber: number, dayIdx: number) => {
if (!draggedEntry || draggedEntry.weekNumber !== weekNumber) return;
setWeeks((prev) =>
prev.map((week) =>
week.weekNumber === weekNumber
? {
...week,
entries: week.entries.map((e) =>
e.id && e.id === draggedEntry.entry.id
? { ...e, dayOfWeek: dayIdx, isManualEdit: true }
: e
),
}
: week
)
);
setDraggedEntry(null);
setDropTargetDay(null);
}, [draggedEntry]);
const handleDragEnd = useCallback(() => {
setDraggedEntry(null);
setDropTargetDay(null);
}, []);
const handleSave = useCallback((updated: CalendarEntry) => {
if (updated.id === '__new__' && creatingForWeek !== null) {
// Create mode: append new entry with a generated id
const newEntry: CalendarEntry = {
...updated,
id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
setWeeks((prev) =>
prev.map((week) =>
week.weekNumber === creatingForWeek
? { ...week, entries: [...week.entries, newEntry] }
: week
)
);
setCreatingForWeek(null);
} else {
// Edit mode: update existing entry by id
setWeeks((prev) =>
prev.map((week) => ({
...week,
entries: week.entries.map((e) =>
e.id && e.id === updated.id ? updated : e
),
}))
);
if (onEntryUpdate && updated.id) {
onEntryUpdate(updated.id, updated);
}
}
setEditingEntry(null);
}, [onEntryUpdate, creatingForWeek]);
const handleClose = useCallback(() => {
setEditingEntry(null);
setCreatingForWeek(null);
}, []);
const toggleFilter = (type: ContentCategory) => {
setFilterType((prev) => (prev === type ? null : type));
};
// Monthly view: flatten all weeks into one 7-day grid
const renderMonthlyView = () => {
const allEntries = weeks.flatMap((w) => w.entries);
const filtered = filterType
? allEntries.filter((e) => e.contentType === filterType)
: allEntries;
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of filtered) {
if (entry.dayOfWeek >= 0 && entry.dayOfWeek <= 6) {
dayCells[entry.dayOfWeek].push(entry);
}
}
return (
<div className="bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
<p className="text-sm font-bold text-[#0A1128] mb-3"> </p>
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div key={day} className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium">
{day}
</div>
))}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[100px] rounded-xl p-1.5 ${
entries.length > 0
? ''
: 'border border-dashed border-slate-200'
}`}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
</div>
))}
</div>
</div>
);
};
const renderEntry = (entry: CalendarEntry, entryIdx: number, weekNumber?: number) => {
const colors = contentTypeColors[entry.contentType];
const ContentIcon = contentTypeIcons[entry.contentType];
const ChannelIcon = channelIconMap[entry.channelIcon] ?? GlobeFilled;
const statusDot = statusDotColors[entry.status ?? 'draft'];
const isDragging = draggedEntry !== null && draggedEntry.entry === entry;
return (
<div
key={entry.id ?? entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-[#6C5CE7]/30 hover:shadow-lg shadow-sm transition-all group relative ${isDragging ? 'opacity-40' : ''}`}
draggable={weekNumber !== undefined}
onDragStart={weekNumber !== undefined ? () => handleDragStart(entry, weekNumber) : undefined}
onDragEnd={weekNumber !== undefined ? handleDragEnd : undefined}
onClick={() => handleEntryClick(entry)}
>
<div className="flex items-center gap-1 mb-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot} flex-shrink-0`} />
<ChannelIcon size={10} className={colors.text} />
<ContentIcon size={10} className={colors.text} />
</div>
<p className="text-[11px] font-medium text-[#0A1128] leading-tight line-clamp-2">
{entry.title}
</p>
{entry.description && (
<p className="text-[9px] text-slate-400 leading-tight mt-0.5 line-clamp-1 hidden group-hover:block">
{entry.description}
</p>
)}
</div>
);
};
return (
<SectionWrapper
id="content-calendar"
title="Content Calendar"
subtitle="콘텐츠 캘린더 (월간)"
dark
>
{/* Toolbar: View Toggle + Status Legend */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-2" data-no-print>
{/* View toggle — styled for dark section bg */}
<div className="flex bg-white/10 rounded-xl p-0.5">
<button
onClick={() => setViewMode('weekly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'weekly'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/50 hover:text-white/80'
}`}
>
</button>
<button
onClick={() => setViewMode('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'monthly'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/50 hover:text-white/80'
}`}
>
</button>
</div>
{/* iCal Export Button */}
<button
onClick={() => exportCalendarToICS(weeks)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white/70 hover:text-white text-xs font-medium transition-all"
data-no-print
>
<CalendarFilled size={12} />
</button>
{/* Status legend with design-system colors */}
<div className="flex gap-3 text-[10px] text-white/50">
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#9B8AD4]" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#7A84D4]" />
</span>
</div>
</div>
{/* Monthly Summary — compact counter pills (click to filter) */}
<div className="flex flex-wrap gap-3 mb-8">
{data.monthlySummary.map((item, i) => {
const colors = contentTypeColors[item.type];
const Icon = contentTypeIcons[item.type];
const isActive = filterType === item.type;
return (
<button
key={item.type}
className={`flex items-center gap-3 px-5 py-3 rounded-2xl border cursor-pointer transition-all ${colors.bg} ${colors.border} ${colors.shadow} ${
isActive ? 'ring-2 ring-white/40 ring-offset-2 ring-offset-transparent' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
onClick={() => toggleFilter(item.type)}
>
<Icon size={16} className={`${colors.text} opacity-70`} />
<div className="flex flex-col items-start">
<span className={`text-2xl font-bold leading-none ${colors.text}`}>{item.count}</span>
<span className={`text-xs font-medium ${colors.text} opacity-70 mt-0.5`}>{item.label}</span>
</div>
</button>
);
})}
{filterType && (
<button
onClick={() => setFilterType(null)}
className="text-xs text-white/40 hover:text-white/70 px-3 self-center transition-colors"
>
×
</button>
)}
</div>
{/* Calendar Content */}
{viewMode === 'monthly' ? (
renderMonthlyView()
) : (
/* Weekly Calendar Grid */
weeks.map((week, weekIdx) => {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of week.entries) {
if (filterType && entry.contentType !== filterType) continue;
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
}
}
return (
<div
key={week.weekNumber}
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
>
<p className="text-sm font-bold text-[#0A1128] mb-3">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div
key={day}
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
>
{day}
</div>
))}
{dayCells.map((entries, dayIdx) => {
const isDropTarget = dropTargetDay?.weekNumber === week.weekNumber && dropTargetDay?.dayIdx === dayIdx;
return (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-1.5 transition-all ${
entries.length > 0
? ''
: 'border border-dashed border-slate-200'
} ${isDropTarget ? 'ring-2 ring-[#6C5CE7]/40 bg-[#F3F0FF] border-[#D5CDF5]' : ''}`}
onDragOver={(e) => handleDragOver(e, week.weekNumber, dayIdx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(week.weekNumber, dayIdx)}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx, week.weekNumber))}
</div>
);
})}
</div>
{/* Add Entry Button */}
<button
onClick={() => handleAddEntry(week.weekNumber)}
className="mt-3 w-full flex items-center justify-center gap-1.5 py-2 rounded-xl border border-dashed border-slate-200 text-xs text-slate-400 hover:text-slate-600 hover:border-slate-300 hover:bg-slate-50/50 transition-all"
data-no-print
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
);
})
)}
{/* Edit Modal */}
{editingEntry && (
<EditEntryModal
entry={editingEntry}
onSave={handleSave}
onClose={handleClose}
/>
)}
</SectionWrapper>
);
}