419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|