diff --git a/src/features/channelconnect/constants/channels.ts b/src/features/channelconnect/constants/channels.ts index fbb6d7b..259a2bd 100644 --- a/src/features/channelconnect/constants/channels.ts +++ b/src/features/channelconnect/constants/channels.ts @@ -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: '강남언니', diff --git a/src/features/channelconnect/store/channelConnectStore.ts b/src/features/channelconnect/store/channelConnectStore.ts new file mode 100644 index 0000000..10ce06c --- /dev/null +++ b/src/features/channelconnect/store/channelConnectStore.ts @@ -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; + 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 = {}; +for (const ch of CHANNELS) { + initialChannels[ch.id] = { status: 'disconnected', values: {} }; +} + +export const useChannelConnectStore = create()( + 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' } + ) +); diff --git a/src/features/channelconnect/ui/ChannelConnectSection.tsx b/src/features/channelconnect/ui/ChannelConnectSection.tsx index 14b1756..5f6df96 100644 --- a/src/features/channelconnect/ui/ChannelConnectSection.tsx +++ b/src/features/channelconnect/ui/ChannelConnectSection.tsx @@ -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 ( -
- -
+
+
{CHANNELS.map(ch => (
-
+
); } diff --git a/src/features/channelconnect/ui/ChannelConnectTitle.tsx b/src/features/channelconnect/ui/ChannelConnectTitle.tsx index 8f05359..6721a65 100644 --- a/src/features/channelconnect/ui/ChannelConnectTitle.tsx +++ b/src/features/channelconnect/ui/ChannelConnectTitle.tsx @@ -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 ( diff --git a/src/features/distribution/constants/distribution.ts b/src/features/distribution/constants/distribution.ts index 116aa2f..a112c14 100644 --- a/src/features/distribution/constants/distribution.ts +++ b/src/features/distribution/constants/distribution.ts @@ -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' }, ]; diff --git a/src/features/distribution/hooks/useDistribution.ts b/src/features/distribution/hooks/useDistribution.ts index 61e6d53..b3aeee0 100644 --- a/src/features/distribution/hooks/useDistribution.ts +++ b/src/features/distribution/hooks/useDistribution.ts @@ -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); diff --git a/src/features/distribution/store/distributionStore.ts b/src/features/distribution/store/distributionStore.ts new file mode 100644 index 0000000..714cf01 --- /dev/null +++ b/src/features/distribution/store/distributionStore.ts @@ -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((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); + }); + }, +})); diff --git a/src/features/distribution/ui/ChannelSelectSection.tsx b/src/features/distribution/ui/ChannelSelect.tsx similarity index 75% rename from src/features/distribution/ui/ChannelSelectSection.tsx rename to src/features/distribution/ui/ChannelSelect.tsx index 2e95dd2..346f47f 100644 --- a/src/features/distribution/ui/ChannelSelectSection.tsx +++ b/src/features/distribution/ui/ChannelSelect.tsx @@ -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 (

배포 채널 선택

- {channels.map(ch => { + {mergedChannels.map(ch => { const Icon = ch.icon; return ( 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 )} - -
+
-
{ch.name} {!ch.connected && ( - - 미연결 - + 미연결 )}

{ch.format}

-
- {ch.status === 'publishing' && ( -
- )} + {ch.status === 'publishing' &&
} {ch.status === 'published' && (
diff --git a/src/features/distribution/ui/ContentPreviewSection.tsx b/src/features/distribution/ui/ContentPreview.tsx similarity index 78% rename from src/features/distribution/ui/ContentPreviewSection.tsx rename to src/features/distribution/ui/ContentPreview.tsx index 7e5deeb..a99cc5d 100644 --- a/src/features/distribution/ui/ContentPreviewSection.tsx +++ b/src/features/distribution/ui/ContentPreview.tsx @@ -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 ( -
+

콘텐츠

-

{MOCK_CONTENT.aspectRatio}

{MOCK_CONTENT.duration}

-
-