페이지 구조 수정

skkim
김성경 2026-04-02 09:38:39 +09:00
parent dfc04af69f
commit 992c232e16
27 changed files with 483 additions and 438 deletions

View File

@ -15,6 +15,18 @@ export const CHANNELS: ChannelDef[] = [
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
],
},
{
id: 'tiktok',
name: 'TikTok',
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
iconKey: 'tiktok',
brandColor: '#000000',
bgColor: '#F5F5F5',
borderColor: '#E0E0E0',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
],
},
{
id: 'instagram_kr',
name: 'Instagram KR',
@ -88,18 +100,6 @@ export const CHANNELS: ChannelDef[] = [
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
],
},
{
id: 'tiktok',
name: 'TikTok',
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
iconKey: 'tiktok',
brandColor: '#000000',
bgColor: '#F5F5F5',
borderColor: '#E0E0E0',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
],
},
{
id: 'gangnamunni',
name: '강남언니',

View File

@ -0,0 +1,61 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CHANNELS } from '../constants/channels';
import type { ChannelState } from '../types';
interface ChannelConnectStore {
channels: Record<string, ChannelState>;
expandedId: string | null;
connectedCount: number;
setExpandedId: (id: string | null) => void;
handleFieldChange: (channelId: string, fieldKey: string, value: string) => void;
handleConnect: (channelId: string) => void;
handleDisconnect: (channelId: string) => void;
}
const initialChannels: Record<string, ChannelState> = {};
for (const ch of CHANNELS) {
initialChannels[ch.id] = { status: 'disconnected', values: {} };
}
export const useChannelConnectStore = create<ChannelConnectStore>()(
persist(
(set) => ({
channels: initialChannels,
expandedId: null,
connectedCount: 0,
setExpandedId: (expandedId) => set({ expandedId }),
handleFieldChange: (channelId, fieldKey, value) => set((s) => ({
channels: {
...s.channels,
[channelId]: {
...s.channels[channelId],
values: { ...s.channels[channelId].values, [fieldKey]: value },
},
},
})),
handleConnect: (channelId) => {
set((s) => ({
channels: { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connecting' } },
}));
setTimeout(() => {
set((s) => {
const channels = { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connected' as const } };
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
return { channels, connectedCount };
});
}, 2000);
},
handleDisconnect: (channelId) => set((s) => {
const channels = { ...s.channels, [channelId]: { status: 'disconnected' as const, values: {} } };
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
return { channels, connectedCount };
}),
}),
{ name: 'channel-connect' }
)
);

View File

@ -1,9 +1,8 @@
import { motion, AnimatePresence } from 'motion/react';
import type { ChannelDef, ChannelState } from '../types';
import { CHANNELS } from '../constants/channels';
import { useChannelConnect } from '../hooks/useChannelConnect';
import { useChannelConnectStore } from '../store/channelConnectStore';
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
import { ChannelConnectTitle } from './ChannelConnectTitle';
interface ChannelCardProps {
ch: ChannelDef;
@ -142,17 +141,15 @@ export function ChannelConnectSection() {
const {
channels,
expandedId,
connectedCount,
setExpandedId,
handleFieldChange,
handleConnect,
handleDisconnect,
} = useChannelConnect();
} = useChannelConnectStore();
return (
<div>
<ChannelConnectTitle connectedCount={connectedCount} />
<div className="max-w-5xl mx-auto px-6 py-12">
<section className="py-12 px-6">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CHANNELS.map(ch => (
<ChannelCard
@ -168,6 +165,6 @@ export function ChannelConnectSection() {
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -1,11 +1,9 @@
import { useNavigate } from 'react-router';
import { CHANNELS } from '../constants/channels';
import { useChannelConnectStore } from '../store/channelConnectStore';
interface ChannelConnectTitleProps {
connectedCount: number;
}
export function ChannelConnectTitle({ connectedCount }: ChannelConnectTitleProps) {
export function ChannelConnectTitle() {
const { connectedCount } = useChannelConnectStore();
const navigate = useNavigate();
return (

View File

@ -21,10 +21,10 @@ export const MOCK_CONTENT: MockContent = {
export const INITIAL_CHANNELS: ChannelTarget[] = [
{ id: 'youtube', name: 'YouTube Shorts', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', connected: true, selected: true, status: 'ready', format: 'Shorts (9:16)' },
{ id: 'instagram_kr', name: 'Instagram Reels', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (9:16)' },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
{ id: 'instagram_kr', name: 'Instagram KR', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (9:16)' },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
{ id: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
{ id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
{ id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, selected: false, status: 'ready', format: 'Video Post' },
{ id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
];

View File

@ -5,7 +5,7 @@ export function useDistribution() {
const [channels, setChannels] = useState(INITIAL_CHANNELS);
const [title, setTitle] = useState(MOCK_CONTENT.title);
const [description, setDescription] = useState(MOCK_CONTENT.description);
const [tags, setTags] = useState(MOCK_CONTENT.tags);
const [tags] = useState(MOCK_CONTENT.tags);
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleHour, setScheduleHour] = useState(9);

View File

@ -0,0 +1,73 @@
import { create } from 'zustand';
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution';
import type { ChannelTarget } from '../types';
interface DistributionStore {
channels: ChannelTarget[];
title: string;
description: string;
tags: string[];
scheduleMode: 'now' | 'scheduled';
scheduleDate: string;
scheduleHour: number;
scheduleMinute: number;
schedulePeriod: 'AM' | 'PM';
isPublishing: boolean;
setTitle: (v: string) => void;
setDescription: (v: string) => void;
setScheduleMode: (v: 'now' | 'scheduled') => void;
setScheduleDate: (v: string) => void;
setScheduleHour: (fn: (h: number) => number) => void;
setScheduleMinute: (fn: (m: number) => number) => void;
setSchedulePeriod: (v: 'AM' | 'PM') => void;
toggleChannel: (id: string) => void;
handlePublish: (selectedIds: string[]) => void;
}
export const useDistributionStore = create<DistributionStore>((set, get) => ({
channels: INITIAL_CHANNELS,
title: MOCK_CONTENT.title,
description: MOCK_CONTENT.description,
tags: MOCK_CONTENT.tags,
scheduleMode: 'now',
scheduleDate: '',
scheduleHour: 9,
scheduleMinute: 0,
schedulePeriod: 'AM',
isPublishing: false,
setTitle: (title) => set({ title }),
setDescription: (description) => set({ description }),
setScheduleMode: (scheduleMode) => set({ scheduleMode }),
setScheduleDate: (scheduleDate) => set({ scheduleDate }),
setScheduleHour: (fn) => set((s) => ({ scheduleHour: fn(s.scheduleHour) })),
setScheduleMinute: (fn) => set((s) => ({ scheduleMinute: fn(s.scheduleMinute) })),
setSchedulePeriod: (schedulePeriod) => set({ schedulePeriod }),
toggleChannel: (id) => set((s) => ({
channels: s.channels.map(c =>
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
),
})),
handlePublish: (selectedIds) => {
const { channels } = get();
const selected = channels.filter(c => selectedIds.includes(c.id));
set({ isPublishing: true });
selected.forEach((ch, i) => {
setTimeout(() => {
set((s) => ({
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'publishing' } : c),
}));
}, i * 1500);
setTimeout(() => {
set((s) => ({
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'published' } : c),
...(i === selected.length - 1 ? { isPublishing: false } : {}),
}));
}, (i + 1) * 1500 + 500);
});
},
}));

View File

@ -1,17 +1,19 @@
import { motion } from 'motion/react';
import type { ChannelTarget } from '../types';
import { useDistributionStore } from '../store/distributionStore';
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
interface ChannelSelectSectionProps {
channels: ChannelTarget[];
toggleChannel: (id: string) => void;
}
export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) {
export function ChannelSelectSection() {
const { channels, toggleChannel } = useDistributionStore();
const { channels: connectedChannels } = useChannelConnectStore();
const mergedChannels = channels.map(ch => ({
...ch,
connected: connectedChannels[ch.id]?.status === 'connected',
}));
return (
<div className="mb-8">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="space-y-3">
{channels.map(ch => {
{mergedChannels.map(ch => {
const Icon = ch.icon;
return (
<motion.div
@ -29,9 +31,7 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
onClick={() => toggleChannel(ch.id)}
disabled={!ch.connected}
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
ch.selected && ch.connected
? 'border-[#6C5CE7] bg-[#6C5CE7]'
: 'border-slate-300 bg-white'
ch.selected && ch.connected ? 'border-[#6C5CE7] bg-[#6C5CE7]' : 'border-slate-300 bg-white'
} disabled:cursor-not-allowed`}
>
{ch.selected && ch.connected && (
@ -40,30 +40,20 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
</svg>
)}
</button>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
{!ch.connected && (
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">
</span>
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]"></span>
)}
</div>
<p className="text-xs text-slate-400">{ch.format}</p>
</div>
<div className="shrink-0">
{ch.status === 'publishing' && (
<div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
)}
{ch.status === 'publishing' && <div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />}
{ch.status === 'published' && (
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">

View File

@ -1,25 +1,17 @@
import { VideoFilled } from '@/components/icons/FilledIcons';
import { MOCK_CONTENT } from '../constants/distribution';
import { useDistributionStore } from '../store/distributionStore';
interface ContentPreviewSectionProps {
title: string;
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
tags: string[];
}
export function ContentPreviewSection({ title, setTitle, description, setDescription, tags }: ContentPreviewSectionProps) {
export function ContentPreviewSection() {
const { title, setTitle, description, setDescription, tags } = useDistributionStore();
return (
<div className="lg:col-span-1">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"></h3>
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] border border-slate-200 flex flex-col items-center justify-center mb-6">
<VideoFilled size={32} className="text-[#9B8AD4] mb-3" />
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
</div>
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<input
@ -28,7 +20,6 @@ export function ContentPreviewSection({ title, setTitle, description, setDescrip
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<textarea
@ -38,15 +29,11 @@ export function ContentPreviewSection({ title, setTitle, description, setDescrip
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span
key={tag}
className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]"
>
<span key={tag} className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
#{tag}
</span>
))}

View File

@ -1,63 +1,19 @@
import { useDistribution } from '../hooks/useDistribution';
import { DistributionTitle } from './DistributionTitle';
import { ContentPreviewSection } from './ContentPreviewSection';
import { ChannelSelectSection } from './ChannelSelectSection';
import { SchedulePublishSection } from './SchedulePublishSection';
import { ContentPreviewSection } from './ContentPreview';
import { ChannelSelectSection } from './ChannelSelect';
import { SchedulePublishSection } from './SchedulePublish';
export function DistributionSection() {
const {
channels,
title, setTitle,
description, setDescription,
tags,
scheduleMode, setScheduleMode,
scheduleDate, setScheduleDate,
scheduleHour, setScheduleHour,
scheduleMinute, setScheduleMinute,
schedulePeriod, setSchedulePeriod,
isPublishing,
selectedChannels,
allPublished,
toggleChannel,
handlePublish,
} = useDistribution();
return (
<div>
<DistributionTitle />
<div className="max-w-5xl mx-auto px-6 py-10">
<section className="py-10 px-6">
<div className="max-w-5xl mx-auto w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<ContentPreviewSection
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
tags={tags}
/>
<div className="lg:col-span-2">
<ChannelSelectSection
channels={channels}
toggleChannel={toggleChannel}
/>
<SchedulePublishSection
scheduleMode={scheduleMode}
setScheduleMode={setScheduleMode}
scheduleDate={scheduleDate}
setScheduleDate={setScheduleDate}
scheduleHour={scheduleHour}
setScheduleHour={setScheduleHour}
scheduleMinute={scheduleMinute}
setScheduleMinute={setScheduleMinute}
schedulePeriod={schedulePeriod}
setSchedulePeriod={setSchedulePeriod}
isPublishing={isPublishing}
selectedChannels={selectedChannels}
allPublished={allPublished}
handlePublish={handlePublish}
/>
<ContentPreviewSection />
<div className="lg:col-span-2 flex flex-col gap-8">
<ChannelSelectSection />
<SchedulePublishSection />
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -1,35 +1,26 @@
import { motion } from 'motion/react';
import { ShareFilled } from '@/components/icons/FilledIcons';
import type { ChannelTarget } from '../types';
import { useDistributionStore } from '../store/distributionStore';
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
interface SchedulePublishSectionProps {
scheduleMode: 'now' | 'scheduled';
setScheduleMode: (v: 'now' | 'scheduled') => void;
scheduleDate: string;
setScheduleDate: (v: string) => void;
scheduleHour: number;
setScheduleHour: (fn: (h: number) => number) => void;
scheduleMinute: number;
setScheduleMinute: (fn: (m: number) => number) => void;
schedulePeriod: 'AM' | 'PM';
setSchedulePeriod: (v: 'AM' | 'PM') => void;
isPublishing: boolean;
selectedChannels: ChannelTarget[];
allPublished: boolean;
handlePublish: () => void;
}
export function SchedulePublishSection({
scheduleMode, setScheduleMode,
scheduleDate, setScheduleDate,
scheduleHour, setScheduleHour,
scheduleMinute, setScheduleMinute,
schedulePeriod, setSchedulePeriod,
isPublishing,
selectedChannels,
allPublished,
handlePublish,
}: SchedulePublishSectionProps) {
export function SchedulePublishSection() {
const {
channels,
scheduleMode, setScheduleMode,
scheduleDate, setScheduleDate,
scheduleHour, setScheduleHour,
scheduleMinute, setScheduleMinute,
schedulePeriod, setSchedulePeriod,
isPublishing,
handlePublish,
} = useDistributionStore();
const { channels: connectedChannels } = useChannelConnectStore();
const mergedChannels = channels.map(ch => ({
...ch,
connected: connectedChannels[ch.id]?.status === 'connected',
}));
const selectedChannels = mergedChannels.filter(c => c.connected && c.selected);
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
return (
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
@ -63,48 +54,32 @@ export function SchedulePublishSection({
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] transition-all appearance-none"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<button onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleHour).padStart(2, '0')}
</div>
<button
onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<button onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<span className="text-xl font-bold text-slate-300">:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<button onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleMinute).padStart(2, '0')}
</div>
<button
onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
>
<button onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<div className="flex rounded-xl overflow-hidden border border-slate-200">
{(['AM', 'PM'] as const).map(p => (
<button
@ -127,7 +102,7 @@ export function SchedulePublishSection({
{!allPublished ? (
<button
onClick={handlePublish}
onClick={() => handlePublish(selectedChannels.map(c => c.id))}
disabled={selectedChannels.length === 0 || isPublishing}
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
@ -155,9 +130,7 @@ export function SchedulePublishSection({
</svg>
</div>
<p className="text-lg font-semibold text-[#0A1128] mb-1"> </p>
<p className="text-sm text-[#4A3A7C]">
{selectedChannels.length}
</p>
<p className="text-sm text-[#4A3A7C]">{selectedChannels.length} </p>
</motion.div>
)}
</div>

View File

@ -1,13 +1,7 @@
import { DistributionTitle } from './DistributionTitle';
import { ContentPreviewSection } from './ContentPreviewSection';
import { ChannelSelectSection } from './ChannelSelectSection';
import { SchedulePublishSection } from './SchedulePublishSection';
import { DistributionSection } from './DistributionSection';
export {
DistributionTitle,
ContentPreviewSection,
ChannelSelectSection,
SchedulePublishSection,
DistributionSection,
};

View File

@ -0,0 +1,16 @@
import { create } from 'zustand';
import { FUNNEL_STEPS, CHANNEL_TREND } from '../constants/performance';
interface PerformanceStore {
period: '7d' | '30d' | '90d';
setPeriod: (p: '7d' | '30d' | '90d') => void;
funnelMax: number;
trendMax: number;
}
export const usePerformanceStore = create<PerformanceStore>((set) => ({
period: '30d',
setPeriod: (period) => set({ period }),
funnelMax: FUNNEL_STEPS[0].value,
trendMax: Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook])),
}));

View File

@ -2,16 +2,20 @@ import { AI_RECOMMENDATIONS } from '../constants/performance';
export function AIRecommendationSection() {
return (
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{AI_RECOMMENDATIONS.map((rec, i) => (
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{AI_RECOMMENDATIONS.map((rec, i) => (
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
</div>
))}
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -17,45 +17,47 @@ function MetricCell({ label, value, delta }: { label: string; value: string; del
export function ChannelPerformanceSection() {
return (
<div className="mb-10">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{CHANNELS.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{CHANNELS.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
<p className="text-xs text-slate-400">{ch.posts} </p>
</div>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
ch.score >= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
'bg-slate-50 text-slate-400'
}`}>
{ch.score || '-'}
</div>
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
<p className="text-xs text-slate-400">{ch.posts} </p>
<div className="grid grid-cols-3 gap-3">
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
</div>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
ch.score >= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
'bg-slate-50 text-slate-400'
}`}>
{ch.score || '-'}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
</div>
</motion.div>
);
})}
</motion.div>
);
})}
</div>
</div>
</div>
</section>
);
}

View File

@ -1,9 +1,13 @@
import { motion } from 'motion/react';
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
import { usePerformanceStore } from '../store/performanceStore';
export function FunnelSection({ funnelMax }: { funnelMax: number }) {
export function FunnelSection() {
const { funnelMax } = usePerformanceStore();
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
@ -64,6 +68,8 @@ export function FunnelSection({ funnelMax }: { funnelMax: number }) {
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -11,55 +11,59 @@ function heatmapColor(value: number): string {
export function HeatmapSection() {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1">× </p>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400"></span>
{[1, 3, 5, 7, 9].map(v => (
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
))}
<span className="text-xs text-slate-400"></span>
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[500px]">
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
<div />
{TIME_SLOTS.map(slot => (
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
))}
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1">× </p>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400"></span>
{[1, 3, 5, 7, 9].map(v => (
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
))}
<span className="text-xs text-slate-400"></span>
</div>
</div>
{DAYS.map((day, di) => (
<motion.div
key={day}
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: di * 0.05 }}
>
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
{HEATMAP_DATA[di].map((val, ti) => (
<div
key={ti}
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-105 cursor-default ${heatmapColor(val)}`}
<div className="overflow-x-auto">
<div className="min-w-[500px]">
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
<div />
{TIME_SLOTS.map(slot => (
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
))}
</div>
{DAYS.map((day, di) => (
<motion.div
key={day}
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: di * 0.05 }}
>
{val > 0 ? `${val * 10}%` : '-'}
</div>
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
{HEATMAP_DATA[di].map((val, ti) => (
<div
key={ti}
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-95 cursor-default ${heatmapColor(val)}`}
>
{val > 0 ? `${val * 10}%` : '-'}
</div>
))}
</motion.div>
))}
</motion.div>
))}
</div>
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
</div>
</div>
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
</div>
</div>
</section>
);
}

View File

@ -1,22 +1,22 @@
import { motion } from 'motion/react';
import { MetricCard } from '@/components/card/MetricCard';
import { OVERVIEW_STATS } from '../constants/performance';
export function OverviewStatsSection() {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
{OVERVIEW_STATS.map((stat, i) => (
<motion.div
key={stat.label}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
<p className="text-xl font-bold text-[#0A1128]">{stat.value}</p>
<p className={`text-xs font-medium mt-1 ${stat.positive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>{stat.delta}</p>
</motion.div>
))}
</div>
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{OVERVIEW_STATS.map((stat) => (
<MetricCard
key={stat.label}
label={stat.label}
value={stat.value}
subtext={stat.delta}
trend={stat.positive ? 'up' : 'down'}
/>
))}
</div>
</div>
</section>
);
}

View File

@ -1,28 +0,0 @@
import { usePerformance } from '../hooks/usePerformance';
import { PerformanceTitle } from './PerformanceTitle';
import { OverviewStatsSection } from './OverviewStatsSection';
import { FunnelSection } from './FunnelSection';
import { TrendSection } from './TrendSection';
import { HeatmapSection } from './HeatmapSection';
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
import { TopContentSection } from './TopContentSection';
import { AIRecommendationSection } from './AIRecommendationSection';
export function PerformanceSection() {
const { period, setPeriod, funnelMax, trendMax } = usePerformance();
return (
<div>
<PerformanceTitle period={period} setPeriod={setPeriod} />
<div className="max-w-6xl mx-auto px-6 py-10">
<OverviewStatsSection />
<FunnelSection funnelMax={funnelMax} />
<TrendSection trendMax={trendMax} />
<HeatmapSection />
<ChannelPerformanceSection />
<TopContentSection />
<AIRecommendationSection />
</div>
</div>
);
}

View File

@ -1,14 +1,10 @@
interface PerformanceTitleProps {
period: '7d' | '30d' | '90d';
setPeriod: (p: '7d' | '30d' | '90d') => void;
}
import { usePerformanceStore } from '../store/performanceStore';
export function PerformanceTitle({ period, setPeriod }: PerformanceTitleProps) {
export function PerformanceTitle() {
const { period, setPeriod } = usePerformanceStore();
return (
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
<div className="bg-[#0A1128] py-14 px-6">
<div className="max-w-6xl mx-auto">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3"> </h1>
<p className="text-purple-200/70 max-w-xl mb-8"> .</p>

View File

@ -17,49 +17,51 @@ const typeColors: Record<string, { bg: string; text: string }> = {
export function TopContentSection() {
return (
<div className="mb-10">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right">CTR</span>
<span className="text-right"></span>
</div>
{TOP_CONTENT.map((content, i) => {
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
const colors = typeColors[content.type] ?? typeColors.blog;
return (
<motion.div
key={content.id}
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
} border-b border-slate-50 last:border-0`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
<TypeIcon size={14} className={colors.text} />
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right">CTR</span>
<span className="text-right"></span>
</div>
{TOP_CONTENT.map((content, i) => {
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
const colors = typeColors[content.type] ?? typeColors.blog;
return (
<motion.div
key={content.id}
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
} border-b border-slate-50 last:border-0`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
<TypeIcon size={14} className={colors.text} />
</div>
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
</div>
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
</div>
<span className="text-xs text-slate-500">{content.channel}</span>
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
<span className={`text-sm font-medium text-right ${
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
}`}>{content.ctr}</span>
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
</motion.div>
);
})}
<span className="text-xs text-slate-500">{content.channel}</span>
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
<span className={`text-sm font-medium text-right ${
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
}`}>{content.ctr}</span>
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
</motion.div>
);
})}
</div>
</div>
</div>
</section>
);
}

View File

@ -1,59 +1,67 @@
import { motion } from 'motion/react';
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
import { usePerformanceStore } from '../store/performanceStore';
export function TrendSection({ trendMax }: { trendMax: number }) {
export function TrendSection() {
const { trendMax } = usePerformanceStore();
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> (: K)</p>
</div>
<div className="flex gap-3">
{TREND_CHANNELS.map(ch => (
<div key={ch.key} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-xs text-slate-500">{ch.label}</span>
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> (: K)</p>
</div>
))}
<div className="flex gap-3">
{TREND_CHANNELS.map(ch => (
<div key={ch.key} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-xs text-slate-500">{ch.label}</span>
</div>
))}
</div>
</div>
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
{CHANNEL_TREND.map((week, wi) => {
const total = week.youtube + week.instagram + week.naver + week.facebook;
return (
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
<p className="text-xs text-slate-500 font-medium">{total}K</p>
<div className="w-full flex flex-col-reverse items-stretch" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
{TREND_CHANNELS.map((ch, ci) => {
const val = week[ch.key];
const segH = (val / total) * 100;
const isFirst = ci === 0;
const isLast = ci === TREND_CHANNELS.length - 1;
return (
<motion.div
key={ch.key}
className={`w-full relative group ${isFirst ? 'rounded-b-xl' : ''} ${isLast ? 'rounded-t-xl' : ''}`}
style={{ height: `${segH}%`, backgroundColor: ch.color }}
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
);
})}
</div>
<p className="text-xs font-medium text-slate-600">{week.week}</p>
</div>
);
})}
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
</div>
</div>
</div>
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
{CHANNEL_TREND.map((week, wi) => {
const total = week.youtube + week.instagram + week.naver + week.facebook;
return (
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
<p className="text-xs text-slate-500 font-medium">{total}K</p>
<div className="w-full flex flex-col-reverse items-stretch rounded-xl overflow-hidden" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
{TREND_CHANNELS.map(ch => {
const val = week[ch.key];
const segH = (val / total) * 100;
return (
<motion.div
key={ch.key}
className="w-full relative group"
style={{ height: `${segH}%`, backgroundColor: ch.color }}
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
);
})}
</div>
<p className="text-xs font-medium text-slate-600">{week.week}</p>
</div>
);
})}
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
</div>
</div>
</section>
);
}

View File

@ -6,7 +6,6 @@ import { HeatmapSection } from './HeatmapSection';
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
import { TopContentSection } from './TopContentSection';
import { AIRecommendationSection } from './AIRecommendationSection';
import { PerformanceSection } from './PerformanceSection';
export {
PerformanceTitle,
@ -17,5 +16,4 @@ export {
ChannelPerformanceSection,
TopContentSection,
AIRecommendationSection,
PerformanceSection,
};

View File

@ -24,18 +24,8 @@ const PAGE_FLOW: FlowStep[] = [
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
{
id: "distribute",
label: "콘텐츠 배포",
navigatePath: "/distribute",
isActive: (p) => p === "/distribute",
},
{
id: "performance",
label: "성과 관리",
navigatePath: "/performance",
isActive: (p) => p === "/performance",
},
{ id: "distribute", label: "콘텐츠 배포", navigatePath: "/distribute", isActive: (p) => p === "/distribute" },
{ id: "performance", label: "성과 관리", navigatePath: "/performance", isActive: (p) => p === "/performance" },
];
function flowIndexForPathname(pathname: string): number {

View File

@ -1,8 +1,10 @@
import { ChannelConnectTitle } from '@/features/channelconnect/ui';
import { ChannelConnectSection } from '@/features/channelconnect/ui';
export function ChannelConnect() {
return (
<div className="flex flex-col w-full">
<ChannelConnectTitle />
<ChannelConnectSection />
</div>
);

View File

@ -1,8 +1,10 @@
import { DistributionTitle } from '@/features/distribution/ui';
import { DistributionSection } from '@/features/distribution/ui';
export function Distribution() {
return (
<div className="flex flex-col w-full">
<DistributionTitle />
<DistributionSection />
</div>
);

View File

@ -1,9 +1,23 @@
import { PerformanceSection } from '@/features/performance/ui';
import { PerformanceTitle } from '@/features/performance/ui';
import { OverviewStatsSection } from '@/features/performance/ui';
import { FunnelSection } from '@/features/performance/ui';
import { TrendSection } from '@/features/performance/ui';
import { HeatmapSection } from '@/features/performance/ui';
import { ChannelPerformanceSection } from '@/features/performance/ui';
import { TopContentSection } from '@/features/performance/ui';
import { AIRecommendationSection } from '@/features/performance/ui';
export function Performance() {
return (
<div className="flex flex-col w-full">
<PerformanceSection />
<PerformanceTitle />
<OverviewStatsSection />
<FunnelSection />
<TrendSection />
<HeatmapSection />
<ChannelPerformanceSection />
<TopContentSection />
<AIRecommendationSection />
</div>
);
}