From 2027ae9b6403c66a8854026a0eb1563b79c76842 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 7 Apr 2026 16:44:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=88=EC=BC=80=ED=8C=85=20=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=20Phase=201~3=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentCalendar: 드래그앤드롭(주차 내 요일 간 이동) + 엔트리 추가 버튼 + iCal Export - BrandingGuide: 색상 팔레트 인라인 편집(스와치 클릭 → hex 팝오버) + DO/DON'T 2컬럼 - WorkflowTracker: 콘텐츠 제작 파이프라인(기획→AI초안→검토→승인→배포), 동영상/이미지+텍스트 분류 - RepurposingProposal: YouTube 인기 영상 리퍼포징 제안 아코디언 섹션 - AssetDetailModal: 에셋 카드 클릭 시 상세 모달 - 디자인 시스템 감사: Lucide 라인 아이콘 제거, 원색(pink/indigo/purple) 제거, 이모지 UI 제거 - "My Assets" → "나의 소재" 일관성 변경 - FilledIcons: DownloadFilled, RocketFilled 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/components/icons/FilledIcons.tsx | 20 + src/components/plan/AssetCollection.tsx | 34 +- src/components/plan/AssetDetailModal.tsx | 217 ++++++++++ src/components/plan/BrandingGuide.tsx | 244 ++++++++--- src/components/plan/ContentCalendar.tsx | 305 +++++++++----- src/components/plan/ContentStrategy.tsx | 13 +- src/components/plan/EditEntryModal.tsx | 18 +- src/components/plan/MyAssetUpload.tsx | 4 +- src/components/plan/PlanCTA.tsx | 11 +- src/components/plan/PlanHeader.tsx | 14 +- src/components/plan/RepurposingProposal.tsx | 165 ++++++++ .../plan/StrategyAdjustmentSection.tsx | 5 +- src/components/plan/WorkflowTracker.tsx | 389 ++++++++++++++++++ src/components/studio/StrategySourceStep.tsx | 2 +- src/data/mockPlan.ts | 144 +++++++ src/hooks/useMarketingPlan.ts | 8 + src/lib/calendarExport.ts | 101 +++++ src/pages/MarketingPlanPage.tsx | 14 +- src/types/plan.ts | 46 +++ 19 files changed, 1547 insertions(+), 207 deletions(-) create mode 100644 src/components/plan/AssetDetailModal.tsx create mode 100644 src/components/plan/RepurposingProposal.tsx create mode 100644 src/components/plan/WorkflowTracker.tsx create mode 100644 src/lib/calendarExport.ts diff --git a/src/components/icons/FilledIcons.tsx b/src/components/icons/FilledIcons.tsx index a5c504b..f85be25 100644 --- a/src/components/icons/FilledIcons.tsx +++ b/src/components/icons/FilledIcons.tsx @@ -281,6 +281,26 @@ export function LinkExternalFilled({ size = 20, className = '' }: IconProps) { * HubSpot-style infinity loop with gradient shading. * Horizontal aspect ratio, scaled to match text cap-height. */ +export function DownloadFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function RocketFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + ); +} + export function PrismFilled({ size = 20, className = '' }: IconProps) { const w = Math.round(size * 1.6); const h = size; diff --git a/src/components/plan/AssetCollection.tsx b/src/components/plan/AssetCollection.tsx index 040944e..f733e50 100644 --- a/src/components/plan/AssetCollection.tsx +++ b/src/components/plan/AssetCollection.tsx @@ -1,8 +1,13 @@ import { useState } from 'react'; import { motion } from 'motion/react'; -import { Youtube } from 'lucide-react'; +import { YoutubeFilled } from '../icons/FilledIcons'; import { SectionWrapper } from '../report/ui/SectionWrapper'; -import type { AssetCollectionData, AssetSource, AssetStatus, AssetType } from '../../types/plan'; +import AssetDetailModal from './AssetDetailModal'; +import type { AssetCollectionData, AssetCard, YouTubeRepurposeItem, AssetSource, AssetStatus, AssetType } from '../../types/plan'; + +type ModalAsset = + | { kind: 'asset'; data: AssetCard } + | { kind: 'youtube'; data: YouTubeRepurposeItem }; interface AssetCollectionProps { data: AssetCollectionData; @@ -23,13 +28,13 @@ const sourceBadgeColors: Record = { homepage: 'bg-slate-100 text-slate-700', naver_place: 'bg-[#F3F0FF] text-[#4A3A7C]', blog: 'bg-[#EFF0FF] text-[#3A3F7C]', - social: 'bg-pink-100 text-pink-700', + social: 'bg-[#FFF0F0] text-[#7C3A4B]', youtube: 'bg-[#FFF0F0] text-[#7C3A4B]', }; const typeBadgeColors: Record = { - photo: 'bg-indigo-50 text-indigo-700', - video: 'bg-purple-50 text-purple-700', + photo: 'bg-[#EFF0FF] text-[#3A3F7C]', + video: 'bg-[#F3F0FF] text-[#4A3A7C]', text: 'bg-[#FFF6ED] text-[#7C5C3A]', }; @@ -47,6 +52,7 @@ function formatViews(views: number): string { export default function AssetCollection({ data }: AssetCollectionProps) { const [activeFilter, setActiveFilter] = useState('all'); + const [selectedAsset, setSelectedAsset] = useState(null); const filteredAssets = activeFilter === 'all' @@ -83,7 +89,8 @@ export default function AssetCollection({ data }: AssetCollectionProps) { return ( setSelectedAsset({ kind: 'asset', data: asset })} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} @@ -122,7 +129,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) { {asset.repurposingSuggestions.map((suggestion, j) => ( {suggestion} @@ -145,14 +152,15 @@ export default function AssetCollection({ data }: AssetCollectionProps) { {data.youtubeRepurpose.map((video, i) => ( setSelectedAsset({ kind: 'youtube', data: video })} initial={{ opacity: 0, x: 20 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ duration: 0.4, delay: i * 0.1 }} >
- +

{video.title}

@@ -162,7 +170,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) { @@ -176,7 +184,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) { {video.repurposeAs.map((suggestion, j) => ( {suggestion} @@ -187,6 +195,10 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
)} + setSelectedAsset(null)} + /> ); } diff --git a/src/components/plan/AssetDetailModal.tsx b/src/components/plan/AssetDetailModal.tsx new file mode 100644 index 0000000..015c338 --- /dev/null +++ b/src/components/plan/AssetDetailModal.tsx @@ -0,0 +1,217 @@ +import { useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { + YoutubeFilled, + GlobeFilled, + VideoFilled, + FileTextFilled, + ShareFilled, + RefreshFilled, + BoltFilled, +} from '../icons/FilledIcons'; +import type { AssetCard, YouTubeRepurposeItem, AssetSource, AssetType, AssetStatus } from '../../types/plan'; + +type ModalAsset = + | { kind: 'asset'; data: AssetCard } + | { kind: 'youtube'; data: YouTubeRepurposeItem }; + +interface AssetDetailModalProps { + asset: ModalAsset | null; + onClose: () => void; +} + +const sourceIcon: Record = { + homepage: GlobeFilled, + naver_place: GlobeFilled, + blog: FileTextFilled, + social: ShareFilled, + youtube: YoutubeFilled, +}; + +const typeLabels: Record = { + photo: '사진', + video: '영상', + text: '텍스트', +}; + +const statusConfig: Record = { + collected: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]', label: '수집 완료' }, + pending: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', border: 'border-[#F5E0C5]', label: '수집 대기' }, + needs_creation: { bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', border: 'border-[#F5D5DC]', label: '제작 필요' }, +}; + +function formatViews(views: number): string { + if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`; + if (views >= 1_000) return `${Math.round(views / 1_000)}K`; + return String(views); +} + +export default function AssetDetailModal({ asset, onClose }: AssetDetailModalProps) { + // Close on Escape + useEffect(() => { + if (!asset) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [asset, onClose]); + + return ( + + {asset && ( + <> + {/* Backdrop */} + + + {/* Modal Panel */} + +
e.stopPropagation()} + > + {/* Header bar */} +
+
+ {asset.kind === 'asset' ? ( + <> + {(() => { + const Icon = sourceIcon[asset.data.source]; + const status = statusConfig[asset.data.status]; + return ( +
+ +
+ ); + })()} +
+

{asset.data.title}

+

{asset.data.sourceLabel} · {typeLabels[asset.data.type]}

+
+ + ) : ( + <> +
+ +
+
+

{asset.data.title}

+

YouTube · {asset.data.type === 'Short' ? 'Shorts' : 'Long-form'} · {formatViews(asset.data.views)} views

+
+ + )} +
+ +
+ + {/* Body */} +
+ {asset.kind === 'asset' && ( + <> + {/* Status + type badges */} +
+ {(() => { + const s = statusConfig[asset.data.status]; + return ( + + {s.label} + + ); + })()} + + {typeLabels[asset.data.type]} + +
+ + {/* Description */} +
+

설명

+

{asset.data.description}

+
+ + {/* Repurposing suggestions */} + {asset.data.repurposingSuggestions.length > 0 && ( +
+
+ +

리퍼포징 제안

+
+
+ {asset.data.repurposingSuggestions.map((s, i) => ( + + {s} + + ))} +
+
+ )} + + )} + + {asset.kind === 'youtube' && ( + <> + {/* Stats */} +
+
+

{formatViews(asset.data.views)}

+

조회수

+
+
+

{asset.data.type === 'Short' ? 'Shorts' : 'Long'}

+

포맷

+
+
+ + {/* Repurpose targets */} + {asset.data.repurposeAs.length > 0 && ( +
+
+ +

리퍼포징 타겟

+
+
+ {asset.data.repurposeAs.map((s, i) => ( + + {s} + + ))} +
+
+ )} + + )} +
+ + {/* Footer CTA */} +
+ +
+
+
+ + )} +
+ ); +} diff --git a/src/components/plan/BrandingGuide.tsx b/src/components/plan/BrandingGuide.tsx index 8539901..db15609 100644 --- a/src/components/plan/BrandingGuide.tsx +++ b/src/components/plan/BrandingGuide.tsx @@ -1,19 +1,19 @@ -import { useState, type ComponentType } from 'react'; +import { useState, useRef, useEffect, type ComponentType } from 'react'; import { motion } from 'motion/react'; +import { ArrowRight } from 'lucide-react'; import { - Youtube, - Instagram, - Facebook, - Globe, - Video, - MessageSquare, - CheckCircle2, - XCircle, - AlertCircle, - ArrowRight, -} from 'lucide-react'; + YoutubeFilled, + InstagramFilled, + FacebookFilled, + GlobeFilled, + VideoFilled, + MessageFilled, + CheckFilled, + CrossFilled, + WarningFilled, +} from '../icons/FilledIcons'; import { SectionWrapper } from '../report/ui/SectionWrapper'; -import type { BrandGuide } from '../../types/plan'; +import type { BrandGuide, ColorSwatch } from '../../types/plan'; import type { BrandInconsistency } from '../../types/report'; interface BrandingGuideProps { @@ -30,16 +30,16 @@ const tabItems = [ type TabKey = (typeof tabItems)[number]['key']; const channelIconMap: Record> = { - youtube: Youtube, - instagram: Instagram, - facebook: Facebook, - globe: Globe, - video: Video, - messagesquare: MessageSquare, + youtube: YoutubeFilled, + instagram: InstagramFilled, + facebook: FacebookFilled, + globe: GlobeFilled, + video: VideoFilled, + messagesquare: MessageFilled, }; function getChannelIcon(icon: string) { - return channelIconMap[icon.toLowerCase()] ?? Globe; + return channelIconMap[icon.toLowerCase()] ?? GlobeFilled; } const statusColor: Record = { @@ -54,8 +54,107 @@ const statusLabel: Record = { missing: 'Missing', }; +/* ─── Color Swatch Editor Popover ─── */ +function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: (newHex: string) => void }) { + const [open, setOpen] = useState(false); + const [hexInput, setHexInput] = useState(swatch.hex); + const popoverRef = useRef(null); + const colorInputRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + // Sync hex input when swatch changes externally + useEffect(() => { setHexInput(swatch.hex); }, [swatch.hex]); + + const applyHex = (value: string) => { + const normalized = value.startsWith('#') ? value : `#${value}`; + if (/^#[0-9A-Fa-f]{6}$/.test(normalized)) { + onUpdate(normalized.toUpperCase()); + setHexInput(normalized.toUpperCase()); + } + }; + + return ( +
+ {/* Swatch — click to open */} + + +
+

{swatch.hex}

+

{swatch.name}

+

{swatch.usage}

+
+ + {/* Edit Popover */} + {open && ( +
+ {/* Native color wheel */} +
+ { onUpdate(e.target.value.toUpperCase()); setHexInput(e.target.value.toUpperCase()); }} + className="w-10 h-10 rounded-xl border border-slate-200 cursor-pointer p-0.5" + /> + 색상 선택 +
+ + {/* Hex input */} +
+ # + setHexInput(`#${e.target.value}`)} + onBlur={(e) => applyHex(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') applyHex(hexInput); }} + maxLength={6} + placeholder="6C5CE7" + className="flex-1 font-mono text-sm border border-slate-200 rounded-lg px-2 py-1.5 text-[#0A1128] focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/30 focus:border-[#6C5CE7]" + /> +
+ +
+ )} +
+ ); +} + /* ─── Visual Identity Tab ─── */ function VisualIdentityTab({ data }: { data: BrandGuide }) { + const [colors, setColors] = useState(data.colors); + + const handleColorUpdate = (idx: number, newHex: string) => { + setColors((prev) => prev.map((c, i) => i === idx ? { ...c, hex: newHex } : c)); + }; + return (

Color Palette

- {data.colors.map((swatch) => ( -
-
-
-

{swatch.hex}

-

{swatch.name}

-

{swatch.usage}

-
-
+ {colors.map((swatch: ColorSwatch, idx: number) => ( + handleColorUpdate(idx, newHex)} + /> ))}
@@ -114,32 +207,57 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) { - {/* Logo Rules */} + {/* Logo Rules — DO / DON'T split columns */}

Logo Rules

-
- {data.logoRules.map((rule) => ( -
-
- {rule.correct ? ( - - ) : ( - - )} -
-

{rule.rule}

-

{rule.description}

-
-
+
+ {/* DO Column */} +
+
+ + DO
- ))} +
+ {data.logoRules.filter((r) => r.correct).map((rule) => ( +
+
+ +
+

{rule.rule}

+

{rule.description}

+
+
+
+ ))} +
+
+ + {/* DON'T Column */} +
+
+ + DON'T +
+
+ {data.logoRules.filter((r) => !r.correct).map((rule) => ( +
+
+ +
+

{rule.rule}

+

{rule.description}

+
+
+
+ ))} +
+
@@ -184,7 +302,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {

- DO + DO

{tone.doExamples.map((example, i) => ( @@ -199,7 +317,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {

- DON'T + DON'T

{tone.dontExamples.map((example, i) => ( @@ -335,9 +453,9 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons {v.isCorrect ? ( - + ) : ( - + )}
@@ -346,8 +464,8 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons {/* Impact */}
-

- +

+ Impact

{item.impact}

@@ -355,8 +473,8 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons {/* Recommendation */}
-

- +

+ Recommendation

{item.recommendation}

diff --git a/src/components/plan/ContentCalendar.tsx b/src/components/plan/ContentCalendar.tsx index 675469d..13edf27 100644 --- a/src/components/plan/ContentCalendar.tsx +++ b/src/components/plan/ContentCalendar.tsx @@ -1,10 +1,18 @@ -import { useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; +import { exportCalendarToICS } from '../../lib/calendarExport'; import { motion } from 'motion/react'; 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'; @@ -37,21 +45,24 @@ const contentTypeIcons: Record = { ad: MegaphoneFilled, }; -const channelEmojiMap: Record = { - youtube: '▶', - instagram: '◎', - facebook: 'f', - blog: '✎', - globe: '◉', - star: '★', - map: '◇', - video: '▷', +// FilledIcon map for channel identification in calendar entries +const channelIconMap: Record = { + 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 = { - draft: 'bg-slate-300', - approved: 'bg-purple-400', - published: 'bg-green-400', + draft: 'bg-slate-400', + approved: 'bg-[#9B8AD4]', + published: 'bg-[#7A84D4]', }; const dayHeaders = ['월', '화', '수', '목', '금', '토', '일']; @@ -59,30 +70,106 @@ const dayHeaders = ['월', '화', '수', '목', '금', '토', '일']; export default function ContentCalendar({ data, planId, onEntryUpdate }: ContentCalendarProps) { const [weeks, setWeeks] = useState(data.weeks); const [editingEntry, setEditingEntry] = useState(null); + const [creatingForWeek, setCreatingForWeek] = useState(null); const [filterType, setFilterType] = useState(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); }, []); - const handleSave = useCallback((updated: CalendarEntry) => { + // 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, 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, - entries: week.entries.map((e) => - (e.id && e.id === updated.id) ? updated : e - ), - })) + 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 + ) ); - if (onEntryUpdate && updated.id) { - onEntryUpdate(updated.id, updated); + 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]); + }, [onEntryUpdate, creatingForWeek]); const handleClose = useCallback(() => { setEditingEntry(null); + setCreatingForWeek(null); }, []); const toggleFilter = (type: ContentCategory) => { @@ -129,24 +216,26 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content ); }; - const renderEntry = (entry: CalendarEntry, entryIdx: number) => { + const renderEntry = (entry: CalendarEntry, entryIdx: number, weekNumber?: number) => { const colors = contentTypeColors[entry.contentType]; - const Icon = contentTypeIcons[entry.contentType]; - const channelSymbol = channelEmojiMap[entry.channelIcon] || '·'; - const statusDot = statusDotColors[entry.status || 'draft']; + const ContentIcon = contentTypeIcons[entry.contentType]; + const ChannelIcon = channelIconMap[entry.channelIcon] ?? GlobeFilled; + const statusDot = statusDotColors[entry.status ?? 'draft']; + const isDragging = draggedEntry?.entry.id === entry.id; return (
handleDragStart(entry, weekNumber) : undefined} + onDragEnd={weekNumber !== undefined ? handleDragEnd : undefined} onClick={() => handleEntryClick(entry)} >
- - {channelSymbol} - - + +

{entry.title} @@ -167,16 +256,16 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content subtitle="콘텐츠 캘린더 (월간)" dark > - {/* Toolbar: View Toggle + Filters */} -

- {/* View toggle */} -
+ {/* Toolbar: View Toggle + Status Legend */} +
+ {/* View toggle — styled for dark section bg */} +
- {/* Status legend */} -
- 초안 - 승인 - 게시됨 + {/* iCal Export Button */} + + + {/* Status legend with design-system colors */} +
+ + 초안 + + + 승인 + + + 게시됨 +
- {/* Monthly Summary */} -
- {data.monthlySummary.map((item) => { + {/* Monthly Summary — compact counter pills (click to filter) */} +
+ {data.monthlySummary.map((item, i) => { const colors = contentTypeColors[item.type]; + const Icon = contentTypeIcons[item.type]; const isActive = filterType === item.type; return ( - toggleFilter(item.type)} initial={{ opacity: 0, y: 10 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} - transition={{ duration: 0.3 }} - onClick={() => toggleFilter(item.type)} - data-no-print={undefined} + transition={{ duration: 0.3, delay: i * 0.07 }} > -
- - {item.label} + +
+ {item.count} + {item.label}
- {item.count} - + ); })} + {filterType && ( + + )}
{/* Calendar Content */} @@ -268,51 +378,42 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content
))} - {dayCells.map((entries, dayIdx) => ( -
0 - ? 'bg-slate-50/50 border border-slate-100' - : 'border border-dashed border-slate-200/60' - }`} - > - {entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))} -
- ))} + {dayCells.map((entries, dayIdx) => { + const isDropTarget = dropTargetDay?.weekNumber === week.weekNumber && dropTargetDay?.dayIdx === dayIdx; + return ( +
0 + ? 'bg-slate-50/50 border border-slate-100' + : 'border border-dashed border-slate-200/60' + } ${isDropTarget ? 'ring-2 ring-[#6C5CE7]/40 bg-[#F3F0FF]/20 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))} +
+ ); + })}
+ + {/* Add Entry Button */} + ); }) )} - {/* Color Legend (clickable filter) */} -
- {(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => { - const colors = contentTypeColors[type]; - const isActive = filterType === type; - return ( - - ); - })} - {filterType && ( - - )} -
- {/* Edit Modal */} {editingEntry && ( = { YouTube: 'bg-[#FFF0F0] text-[#7C3A4B]', - Instagram: 'bg-pink-100 text-pink-700', + Instagram: 'bg-[#FFF0F0] text-[#7C3A4B]', Blog: 'bg-[#EFF0FF] text-[#3A3F7C]', '블로그': 'bg-[#EFF0FF] text-[#3A3F7C]', '네이버블로그': 'bg-[#F3F0FF] text-[#4A3A7C]', '네이버': 'bg-[#F3F0FF] text-[#4A3A7C]', - Facebook: 'bg-indigo-100 text-indigo-700', + Facebook: 'bg-[#EFF0FF] text-[#3A3F7C]', '홈페이지': 'bg-slate-100 text-slate-700', Website: 'bg-slate-100 text-slate-700', - TikTok: 'bg-purple-100 text-purple-700', + TikTok: 'bg-[#F3F0FF] text-[#4A3A7C]', '카카오': 'bg-[#FFF6ED] text-[#7C5C3A]', }; @@ -168,7 +169,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {

{step.name}

{step.description}

- + {step.owner} @@ -197,7 +198,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) { {/* Source Card */}
-
diff --git a/src/components/plan/EditEntryModal.tsx b/src/components/plan/EditEntryModal.tsx index 453bd07..72033f6 100644 --- a/src/components/plan/EditEntryModal.tsx +++ b/src/components/plan/EditEntryModal.tsx @@ -28,8 +28,8 @@ const DAYS = ['월', '화', '수', '목', '금', '토', '일']; const STATUS_OPTIONS: { value: CalendarEntry['status']; label: string; color: string }[] = [ { value: 'draft', label: '초안', color: 'bg-slate-200 text-slate-600' }, - { value: 'approved', label: '승인', color: 'bg-purple-100 text-purple-700' }, - { value: 'published', label: '게시됨', color: 'bg-green-100 text-green-700' }, + { value: 'approved', label: '승인', color: 'bg-[#F3F0FF] text-[#4A3A7C]' }, + { value: 'published', label: '게시됨', color: 'bg-[#EFF0FF] text-[#3A3F7C]' }, ]; export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: EditEntryModalProps) { @@ -118,7 +118,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: type="text" value={title} onChange={(e) => setTitle(e.target.value)} - className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all" + className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none transition-all" placeholder="콘텐츠 제목을 입력하세요" />
@@ -130,7 +130,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: value={description} onChange={(e) => setDescription(e.target.value)} rows={3} - className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all resize-none" + className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none transition-all resize-none" placeholder="AI가 생성한 제작 가이드 또는 직접 메모를 입력하세요" />
@@ -142,7 +142,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: setContentType(e.target.value as ContentCategory)} - className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white" + className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none bg-white" > {CONTENT_TYPES.map((ct) => ( @@ -170,7 +170,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: