diff --git a/.gitignore b/.gitignore index a547bf3..b59b388 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr *.local +.env + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -22,3 +24,5 @@ dist-ssr *.njsproj *.sln *.sw? + +zzz/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 79fd4d7..4ed7e24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", + "framer-motion": "^12.38.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", @@ -65,6 +67,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1566,6 +1569,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -2246,6 +2250,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2256,6 +2261,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2315,6 +2321,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2592,6 +2599,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2717,6 +2725,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3142,6 +3151,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3453,6 +3463,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3927,6 +3964,47 @@ "node": "*" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4170,6 +4248,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4179,6 +4258,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4250,6 +4330,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4464,7 +4545,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -4486,6 +4566,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4572,6 +4653,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4723,6 +4805,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index fdd494f..22f1e23 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dependencies": { "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", + "framer-motion": "^12.38.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", diff --git a/src/app/App.tsx b/src/app/App.tsx index 0c9efeb..f6e2829 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,6 +4,9 @@ import MainLayout from "@/layouts/MainLayout"; // pages import { Home } from "@/pages/Home"; +import { ChannelConnect } from "@/pages/ChannelConnect"; +import { Distribution } from "@/pages/Distribution"; +import { Performance } from "@/pages/Performance"; function App() { @@ -11,6 +14,9 @@ function App() { }> } /> + } /> + } /> + } /> ) diff --git a/src/components/icons/FilledIcons.tsx b/src/components/icons/FilledIcons.tsx new file mode 100644 index 0000000..6bdde08 --- /dev/null +++ b/src/components/icons/FilledIcons.tsx @@ -0,0 +1,161 @@ +/** + * Filled/Shape-style icons for Channel Strategy & Content Calendar. + * Soft pastel colors, no outlines — all shapes use fill only. + */ + +interface IconProps { + size?: number; + className?: string; + color?: string; + style?: { color?: string; [key: string]: unknown }; +} + +export function YoutubeFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + ); +} + +export function InstagramFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + ); +} + +export function FacebookFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + ); +} + +export function GlobeFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + + ); +} + +export function VideoFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + ); +} + +export function MessageFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + ); +} + +export function CalendarFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function FileTextFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + + ); +} + +export function ShareFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + + + ); +} + +export function MegaphoneFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + ); +} + +export function TiktokFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + ); +} + +export function MusicFilled({ size = 20, className = '', color, style }: IconProps) { + return ( + + + + + + + + + ); +} + +export function PrismFilled({ size = 20, className = '', color, style }: IconProps) { + const w = Math.round(size * 1.6); + const h = size; + const id = `inf-grad-${size}`; + return ( + + + + + + + + + + + ); +} diff --git a/src/features/channelconnect/constants/channels.ts b/src/features/channelconnect/constants/channels.ts new file mode 100644 index 0000000..fbb6d7b --- /dev/null +++ b/src/features/channelconnect/constants/channels.ts @@ -0,0 +1,128 @@ +/** 채널 연결 목록 */ + +import type { ChannelDef } from '../types'; + +export const CHANNELS: ChannelDef[] = [ + { + id: 'youtube', + name: 'YouTube', + description: '채널 연동으로 영상 자동 업로드, 성과 분석', + iconKey: 'youtube', + brandColor: '#FF0000', + bgColor: '#FFF0F0', + borderColor: '#F5D5DC', + fields: [ + { key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' }, + ], + }, + { + id: 'instagram_kr', + name: 'Instagram KR', + description: '한국 계정 — Reels, Feed, Stories 자동 게시', + iconKey: 'instagram', + brandColor: '#E1306C', + bgColor: '#FFF0F5', + borderColor: '#F5D0DC', + fields: [ + { key: 'handle', label: '핸들', placeholder: '@viewclinic_kr' }, + ], + }, + { + id: 'instagram_en', + name: 'Instagram EN', + description: '글로벌 계정 — 해외 환자 대상 콘텐츠', + iconKey: 'instagram', + brandColor: '#E1306C', + bgColor: '#FFF0F5', + borderColor: '#F5D0DC', + fields: [ + { key: 'handle', label: '핸들', placeholder: '@viewplasticsurgery' }, + ], + }, + { + id: 'facebook_kr', + name: 'Facebook KR', + description: '한국 페이지 — 광고 리타겟, 콘텐츠 배포', + iconKey: 'facebook', + brandColor: '#1877F2', + bgColor: '#F0F4FF', + borderColor: '#C5D5F5', + fields: [ + { key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPage' }, + ], + }, + { + id: 'facebook_en', + name: 'Facebook EN', + description: '글로벌 페이지 — 해외 환자 유입', + iconKey: 'facebook', + brandColor: '#1877F2', + bgColor: '#F0F4FF', + borderColor: '#C5D5F5', + fields: [ + { key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPageEN' }, + ], + }, + { + id: 'naver_blog', + name: 'Naver Blog', + description: 'SEO 블로그 포스트 자동 게시, 키워드 최적화', + iconKey: 'globe', + brandColor: '#03C75A', + bgColor: '#F0FFF5', + borderColor: '#C5F5D5', + fields: [ + { key: 'blogUrl', label: '블로그 URL', placeholder: 'https://blog.naver.com/yourblog' }, + { key: 'apiKey', label: 'API Key', placeholder: 'Naver Open API Key', type: 'password' }, + ], + }, + { + id: 'naver_place', + name: 'Naver Place', + description: '플레이스 정보 동기화, 리뷰 모니터링', + iconKey: 'globe', + brandColor: '#03C75A', + bgColor: '#F0FFF5', + borderColor: '#C5F5D5', + fields: [ + { 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: '강남언니', + description: '리뷰 모니터링, 평점 추적, 시술 정보 동기화', + iconKey: 'globe', + brandColor: '#6B2D8B', + bgColor: '#F3F0FF', + borderColor: '#D5CDF5', + fields: [ + { key: 'hospitalUrl', label: '병원 페이지 URL', placeholder: 'https://gangnamunni.com/hospitals/...' }, + ], + }, + { + id: 'website', + name: 'Website', + description: '홈페이지 SEO 모니터링, 트래킹 픽셀 관리', + iconKey: 'globe', + brandColor: '#6C5CE7', + bgColor: '#F3F0FF', + borderColor: '#D5CDF5', + fields: [ + { key: 'url', label: '웹사이트 URL', placeholder: 'https://www.yourclinic.com' }, + { key: 'gaId', label: 'Google Analytics ID', placeholder: 'G-XXXXXXXXXX' }, + ], + }, +]; diff --git a/src/features/channelconnect/hooks/useChannelConnect.ts b/src/features/channelconnect/hooks/useChannelConnect.ts new file mode 100644 index 0000000..ccfd0ff --- /dev/null +++ b/src/features/channelconnect/hooks/useChannelConnect.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; +import { CHANNELS } from '../constants/channels'; +import type { ChannelState } from '../types'; + +export function useChannelConnect() { + const [channels, setChannels] = useState>(() => { + const init: Record = {}; + for (const ch of CHANNELS) { + init[ch.id] = { status: 'disconnected', values: {} }; + } + return init; + }); + + const [expandedId, setExpandedId] = useState(null); + + const handleFieldChange = useCallback((channelId: string, fieldKey: string, value: string) => { + setChannels(prev => ({ + ...prev, + [channelId]: { + ...prev[channelId], + values: { ...prev[channelId].values, [fieldKey]: value }, + }, + })); + }, []); + + const handleConnect = useCallback((channelId: string) => { + setChannels(prev => ({ + ...prev, + [channelId]: { ...prev[channelId], status: 'connecting' }, + })); + setTimeout(() => { + setChannels(prev => ({ + ...prev, + [channelId]: { ...prev[channelId], status: 'connected' }, + })); + }, 2000); + }, []); + + const handleDisconnect = useCallback((channelId: string) => { + setChannels(prev => ({ + ...prev, + [channelId]: { status: 'disconnected', values: {} }, + })); + }, []); + + const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length; + + return { + channels, + expandedId, + connectedCount, + setExpandedId, + handleFieldChange, + handleConnect, + handleDisconnect, + }; +} diff --git a/src/features/channelconnect/types/index.ts b/src/features/channelconnect/types/index.ts new file mode 100644 index 0000000..fd91996 --- /dev/null +++ b/src/features/channelconnect/types/index.ts @@ -0,0 +1,17 @@ +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; + +export interface ChannelDef { + id: string; + name: string; + description: string; + iconKey: string; + brandColor: string; + bgColor: string; + borderColor: string; + fields: { key: string; label: string; placeholder: string; type?: string }[]; +} + +export interface ChannelState { + status: ConnectionStatus; + values: Record; +} diff --git a/src/features/channelconnect/ui/ChannelConnectSection.tsx b/src/features/channelconnect/ui/ChannelConnectSection.tsx new file mode 100644 index 0000000..14b1756 --- /dev/null +++ b/src/features/channelconnect/ui/ChannelConnectSection.tsx @@ -0,0 +1,173 @@ +import { motion, AnimatePresence } from 'motion/react'; +import type { ChannelDef, ChannelState } from '../types'; +import { CHANNELS } from '../constants/channels'; +import { useChannelConnect } from '../hooks/useChannelConnect'; +import { CHANNEL_ICON_MAP } from '../utils/channelIconMap'; +import { ChannelConnectTitle } from './ChannelConnectTitle'; + +interface ChannelCardProps { + ch: ChannelDef; + state: ChannelState; + isExpanded: boolean; + onToggle: () => void; + onFieldChange: (fieldKey: string, value: string) => void; + onConnect: () => void; + onDisconnect: () => void; +} + +function ChannelCard({ ch, state, isExpanded, onToggle, onFieldChange, onConnect, onDisconnect }: ChannelCardProps) { + const Icon = CHANNEL_ICON_MAP[ch.iconKey]; + const allFieldsFilled = ch.fields.every(f => (state.values[f.key] ?? '').trim().length > 0); + + return ( + + + + + {isExpanded && ( + +
+ {state.status === 'connected' ? ( +
+
+
+ 연결 상태: 활성 +
+ {ch.fields.map(f => ( +
+ {f.label} +

+ {f.type === 'password' ? '••••••••' : state.values[f.key]} +

+
+ ))} + +
+ ) : ( +
+ {ch.fields.map(f => ( +
+ + onFieldChange(f.key, e.target.value)} + placeholder={f.placeholder} + className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 placeholder:text-slate-300 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all" + /> +
+ ))} + +
+ + +
+
+ )} +
+ + )} + + + ); +} + +export function ChannelConnectSection() { + const { + channels, + expandedId, + connectedCount, + setExpandedId, + handleFieldChange, + handleConnect, + handleDisconnect, + } = useChannelConnect(); + + return ( +
+ +
+
+ {CHANNELS.map(ch => ( + setExpandedId(expandedId === ch.id ? null : ch.id)} + onFieldChange={(fieldKey, value) => handleFieldChange(ch.id, fieldKey, value)} + onConnect={() => handleConnect(ch.id)} + onDisconnect={() => handleDisconnect(ch.id)} + /> + ))} +
+
+
+ ); +} diff --git a/src/features/channelconnect/ui/ChannelConnectTitle.tsx b/src/features/channelconnect/ui/ChannelConnectTitle.tsx new file mode 100644 index 0000000..8f05359 --- /dev/null +++ b/src/features/channelconnect/ui/ChannelConnectTitle.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router'; +import { CHANNELS } from '../constants/channels'; + +interface ChannelConnectTitleProps { + connectedCount: number; +} + +export function ChannelConnectTitle({ connectedCount }: ChannelConnectTitleProps) { + const navigate = useNavigate(); + + return ( +
+
+

Channel Integration

+

+ 채널 연결 +

+

+ 소셜 미디어와 플랫폼을 연결하여 콘텐츠를 자동으로 배포하고 성과를 추적하세요. +

+ +
+
+
0 ? 'bg-[#6C5CE7]' : 'bg-slate-300'}`} /> + + {connectedCount} / {CHANNELS.length} 연결됨 + +
+ {connectedCount > 0 && ( + + )} +
+
+
+ ); +} diff --git a/src/features/channelconnect/ui/index.tsx b/src/features/channelconnect/ui/index.tsx new file mode 100644 index 0000000..99b68e5 --- /dev/null +++ b/src/features/channelconnect/ui/index.tsx @@ -0,0 +1,7 @@ +import { ChannelConnectTitle } from './ChannelConnectTitle'; +import { ChannelConnectSection } from './ChannelConnectSection'; + +export { + ChannelConnectTitle, + ChannelConnectSection, +}; diff --git a/src/features/channelconnect/utils/channelIconMap.ts b/src/features/channelconnect/utils/channelIconMap.ts new file mode 100644 index 0000000..69592b2 --- /dev/null +++ b/src/features/channelconnect/utils/channelIconMap.ts @@ -0,0 +1,18 @@ +import type { ComponentType } from 'react'; +import { + YoutubeFilled, + InstagramFilled, + FacebookFilled, + GlobeFilled, + TiktokFilled, +} from '@/components/icons/FilledIcons'; + +type IconComponent = ComponentType<{ size?: number; className?: string; color?: string; style?: { color?: string; [key: string]: unknown } }>; + +export const CHANNEL_ICON_MAP: Record = { + youtube: YoutubeFilled, + instagram: InstagramFilled, + facebook: FacebookFilled, + globe: GlobeFilled, + tiktok: TiktokFilled, +}; diff --git a/src/features/distribution/constants/distribution.ts b/src/features/distribution/constants/distribution.ts new file mode 100644 index 0000000..116aa2f --- /dev/null +++ b/src/features/distribution/constants/distribution.ts @@ -0,0 +1,30 @@ +/** 배포 페이지 상수 */ + +import { + YoutubeFilled, + InstagramFilled, + FacebookFilled, + GlobeFilled, + TiktokFilled, +} from '@/components/icons/FilledIcons'; +import type { ChannelTarget, MockContent } from '../types'; + +export const MOCK_CONTENT: MockContent = { + title: '한번에 성공하는 코성형, VIEW의 비결', + description: '코성형은 얼굴의 중심을 결정하는 중요한 수술입니다. VIEW 성형외과는 21년간 축적된 노하우를 바탕으로 자연스러운 결과를 만들어냅니다.', + type: 'video', + duration: '0:58', + aspectRatio: '9:16', + tags: ['코성형', '뷰성형외과', '강남성형외과', '코수술', '자연스러운코'], + thumbnail: null, +}; + +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: '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' }, +]; diff --git a/src/features/distribution/hooks/useDistribution.ts b/src/features/distribution/hooks/useDistribution.ts new file mode 100644 index 0000000..61e6d53 --- /dev/null +++ b/src/features/distribution/hooks/useDistribution.ts @@ -0,0 +1,61 @@ +import { useState, useCallback } from 'react'; +import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution'; + +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 [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now'); + const [scheduleDate, setScheduleDate] = useState(''); + const [scheduleHour, setScheduleHour] = useState(9); + const [scheduleMinute, setScheduleMinute] = useState(0); + const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM'); + const [isPublishing, setIsPublishing] = useState(false); + + const selectedChannels = channels.filter(c => c.connected && c.selected); + const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published'); + + const toggleChannel = useCallback((id: string) => { + setChannels(prev => prev.map(c => + c.id === id && c.connected ? { ...c, selected: !c.selected } : c + )); + }, []); + + const handlePublish = useCallback(() => { + setIsPublishing(true); + + const selected = channels.filter(c => c.connected && c.selected); + selected.forEach((ch, i) => { + setTimeout(() => { + setChannels(prev => prev.map(c => + c.id === ch.id ? { ...c, status: 'publishing' } : c + )); + }, i * 1500); + + setTimeout(() => { + setChannels(prev => prev.map(c => + c.id === ch.id ? { ...c, status: 'published' } : c + )); + if (i === selected.length - 1) setIsPublishing(false); + }, (i + 1) * 1500 + 500); + }); + }, [channels]); + + return { + channels, + title, setTitle, + description, setDescription, + tags, + scheduleMode, setScheduleMode, + scheduleDate, setScheduleDate, + scheduleHour, setScheduleHour, + scheduleMinute, setScheduleMinute, + schedulePeriod, setSchedulePeriod, + isPublishing, + selectedChannels, + allPublished, + toggleChannel, + handlePublish, + }; +} diff --git a/src/features/distribution/types/index.ts b/src/features/distribution/types/index.ts new file mode 100644 index 0000000..99e01f4 --- /dev/null +++ b/src/features/distribution/types/index.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from 'react'; + +export type DistributeStatus = 'ready' | 'publishing' | 'published' | 'failed'; + +export interface ChannelTarget { + id: string; + name: string; + icon: ComponentType<{ size?: number; className?: string; style?: { color?: string } }>; + brandColor: string; + bgColor: string; + connected: boolean; + selected: boolean; + status: DistributeStatus; + format: string; +} + +export interface MockContent { + title: string; + description: string; + type: 'video'; + duration: string; + aspectRatio: string; + tags: string[]; + thumbnail: string | null; +} diff --git a/src/features/distribution/ui/ChannelSelectSection.tsx b/src/features/distribution/ui/ChannelSelectSection.tsx new file mode 100644 index 0000000..2e95dd2 --- /dev/null +++ b/src/features/distribution/ui/ChannelSelectSection.tsx @@ -0,0 +1,86 @@ +import { motion } from 'motion/react'; +import type { ChannelTarget } from '../types'; + +interface ChannelSelectSectionProps { + channels: ChannelTarget[]; + toggleChannel: (id: string) => void; +} + +export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) { + return ( +
+

배포 채널 선택

+
+ {channels.map(ch => { + const Icon = ch.icon; + return ( + + + +
+ +
+ +
+
+ {ch.name} + {!ch.connected && ( + + 미연결 + + )} +
+

{ch.format}

+
+ +
+ {ch.status === 'publishing' && ( +
+ )} + {ch.status === 'published' && ( +
+ + + +
+ )} + {ch.status === 'failed' && ( +
+ ! +
+ )} +
+ + ); + })} +
+
+ ); +} diff --git a/src/features/distribution/ui/ContentPreviewSection.tsx b/src/features/distribution/ui/ContentPreviewSection.tsx new file mode 100644 index 0000000..7e5deeb --- /dev/null +++ b/src/features/distribution/ui/ContentPreviewSection.tsx @@ -0,0 +1,57 @@ +import { VideoFilled } from '@/components/icons/FilledIcons'; +import { MOCK_CONTENT } from '../constants/distribution'; + +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) { + return ( +
+

콘텐츠

+ +
+ +

{MOCK_CONTENT.aspectRatio}

+

{MOCK_CONTENT.duration}

+
+ +
+ + setTitle(e.target.value)} + 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" + /> +
+ +
+ +