From 5c912e4bf839d9c68992969925c96fae643a2ab0 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 2 Feb 2026 12:39:51 +0900 Subject: [PATCH] =?UTF-8?q?youtube=20=EA=B3=84=EC=A0=95=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/social_api_spec.json | 585 +++++++++++ index.css | 1159 +++++++++++++++++++++ src/App.tsx | 13 + src/components/Sidebar.tsx | 2 +- src/pages/Dashboard/CompletionContent.tsx | 228 +++- src/pages/Dashboard/DashboardContent.tsx | 735 +++++++++++-- src/pages/Dashboard/GenerationFlow.tsx | 13 +- src/pages/Social/SocialConnectError.tsx | 48 + src/pages/Social/SocialConnectSuccess.tsx | 121 +++ src/types/api.ts | 40 + src/utils/api.ts | 60 ++ 11 files changed, 2918 insertions(+), 86 deletions(-) create mode 100644 api/social_api_spec.json create mode 100644 src/pages/Social/SocialConnectError.tsx create mode 100644 src/pages/Social/SocialConnectSuccess.tsx diff --git a/api/social_api_spec.json b/api/social_api_spec.json new file mode 100644 index 0000000..9632ccf --- /dev/null +++ b/api/social_api_spec.json @@ -0,0 +1,585 @@ +{ + "info": { + "title": "Social Media Integration API", + "version": "1.0.0", + "description": "소셜 미디어 연동 및 영상 업로드 API 명세서", + "baseUrl": "http://localhost:8000" + }, + "authentication": { + "type": "Bearer Token", + "header": "Authorization", + "format": "Bearer {access_token}", + "description": "카카오 로그인 후 발급받은 JWT access_token 사용" + }, + "endpoints": { + "oauth": { + "connect": { + "name": "소셜 계정 연동 시작", + "method": "GET", + "url": "/social/oauth/{platform}/connect", + "description": "OAuth 인증 URL을 생성합니다. 반환된 auth_url로 사용자를 리다이렉트하세요.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "연동할 플랫폼 (현재 youtube만 지원)" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=xxx&response_type=code&scope=xxx&state=xxx", + "state": "abc123xyz789", + "platform": "youtube" + } + }, + "error": { + "401": { + "detail": "인증이 필요합니다." + }, + "422": { + "detail": "지원하지 않는 플랫폼입니다." + } + } + }, + "frontendAction": "auth_url로 window.location.href 또는 새 창으로 리다이렉트" + }, + "callback": { + "name": "OAuth 콜백 (백엔드 자동 처리)", + "method": "GET", + "url": "/social/oauth/{platform}/callback", + "description": "Google에서 자동으로 호출됩니다. 프론트엔드에서 직접 호출하지 마세요.", + "authentication": false, + "note": "연동 성공 시 프론트엔드의 /social/connect/success 페이지로 리다이렉트됩니다.", + "redirectOnSuccess": { + "url": "{PROJECT_DOMAIN}/social/connect/success", + "queryParams": { + "platform": "youtube", + "account_id": 1 + } + }, + "redirectOnError": { + "url": "{PROJECT_DOMAIN}/social/connect/error", + "queryParams": { + "platform": "youtube", + "error": "에러 메시지" + } + } + }, + "getAccounts": { + "name": "연동된 계정 목록 조회", + "method": "GET", + "url": "/social/oauth/accounts", + "description": "현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.", + "authentication": true, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "accounts": [ + { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890abcdef", + "platform_username": "@mychannel", + "display_name": "My YouTube Channel", + "profile_image_url": "https://yt3.ggpht.com/...", + "is_active": true, + "connected_at": "2024-01-15T12:00:00", + "platform_data": { + "channel_id": "UC1234567890abcdef", + "channel_title": "My YouTube Channel", + "subscriber_count": "1000", + "video_count": "50" + } + } + ], + "total": 1 + } + } + } + }, + "getAccountByPlatform": { + "name": "특정 플랫폼 연동 계정 조회", + "method": "GET", + "url": "/social/oauth/accounts/{platform}", + "description": "특정 플랫폼에 연동된 계정 정보를 반환합니다.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "조회할 플랫폼" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890abcdef", + "platform_username": "@mychannel", + "display_name": "My YouTube Channel", + "profile_image_url": "https://yt3.ggpht.com/...", + "is_active": true, + "connected_at": "2024-01-15T12:00:00", + "platform_data": { + "channel_id": "UC1234567890abcdef", + "channel_title": "My YouTube Channel", + "subscriber_count": "1000", + "video_count": "50" + } + } + }, + "error": { + "404": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + } + } + } + }, + "disconnect": { + "name": "소셜 계정 연동 해제", + "method": "DELETE", + "url": "/social/oauth/{platform}/disconnect", + "description": "소셜 미디어 계정 연동을 해제합니다.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "연동 해제할 플랫폼" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "message": "youtube 계정 연동이 해제되었습니다." + } + }, + "error": { + "404": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + } + } + } + } + }, + "upload": { + "create": { + "name": "소셜 플랫폼에 영상 업로드 요청", + "method": "POST", + "url": "/social/upload", + "description": "영상을 소셜 미디어 플랫폼에 업로드합니다. 백그라운드에서 처리됩니다.", + "authentication": true, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "Content-Type": "application/json" + }, + "body": { + "video_id": { + "type": "integer", + "required": true, + "description": "업로드할 영상 ID (Video 테이블의 id)", + "example": 123 + }, + "platform": { + "type": "string", + "required": true, + "enum": ["youtube"], + "description": "업로드할 플랫폼", + "example": "youtube" + }, + "title": { + "type": "string", + "required": true, + "maxLength": 100, + "description": "영상 제목", + "example": "나의 첫 영상" + }, + "description": { + "type": "string", + "required": false, + "maxLength": 5000, + "description": "영상 설명", + "example": "이 영상은 테스트 영상입니다." + }, + "tags": { + "type": "array", + "required": false, + "items": "string", + "description": "태그 목록", + "example": ["여행", "vlog", "일상"] + }, + "privacy_status": { + "type": "string", + "required": false, + "enum": ["public", "unlisted", "private"], + "default": "private", + "description": "공개 상태", + "example": "private" + }, + "platform_options": { + "type": "object", + "required": false, + "description": "플랫폼별 추가 옵션", + "example": { + "category_id": "22" + } + } + }, + "example": { + "video_id": 123, + "platform": "youtube", + "title": "나의 첫 영상", + "description": "이 영상은 테스트 영상입니다.", + "tags": ["여행", "vlog"], + "privacy_status": "private", + "platform_options": { + "category_id": "22" + } + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "upload_id": 456, + "platform": "youtube", + "status": "pending", + "message": "업로드 요청이 접수되었습니다." + } + }, + "error": { + "404_video": { + "detail": "영상을 찾을 수 없습니다." + }, + "404_account": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + }, + "400": { + "detail": "영상이 아직 준비되지 않았습니다." + } + } + }, + "youtubeCategoryIds": { + "1": "Film & Animation", + "2": "Autos & Vehicles", + "10": "Music", + "15": "Pets & Animals", + "17": "Sports", + "19": "Travel & Events", + "20": "Gaming", + "22": "People & Blogs (기본값)", + "23": "Comedy", + "24": "Entertainment", + "25": "News & Politics", + "26": "Howto & Style", + "27": "Education", + "28": "Science & Technology", + "29": "Nonprofits & Activism" + } + }, + "getStatus": { + "name": "업로드 상태 조회", + "method": "GET", + "url": "/social/upload/{upload_id}/status", + "description": "특정 업로드 작업의 상태를 조회합니다. 폴링으로 상태를 확인하세요.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "upload_progress": 100, + "title": "나의 첫 영상", + "platform_video_id": "dQw4w9WgXcQ", + "platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "error_message": null, + "retry_count": 0, + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00" + } + }, + "error": { + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + }, + "statusValues": { + "pending": "업로드 대기 중", + "uploading": "업로드 진행 중 (upload_progress 확인)", + "processing": "플랫폼에서 처리 중", + "completed": "업로드 완료 (platform_url 사용 가능)", + "failed": "업로드 실패 (error_message 확인)", + "cancelled": "업로드 취소됨" + }, + "pollingRecommendation": { + "interval": "3초", + "maxAttempts": 100, + "stopConditions": ["completed", "failed", "cancelled"] + } + }, + "getHistory": { + "name": "업로드 이력 조회", + "method": "GET", + "url": "/social/upload/history", + "description": "사용자의 소셜 미디어 업로드 이력을 조회합니다.", + "authentication": true, + "queryParameters": { + "platform": { + "type": "string", + "required": false, + "enum": ["youtube"], + "description": "플랫폼 필터" + }, + "status": { + "type": "string", + "required": false, + "enum": ["pending", "uploading", "processing", "completed", "failed", "cancelled"], + "description": "상태 필터" + }, + "page": { + "type": "integer", + "required": false, + "default": 1, + "description": "페이지 번호" + }, + "size": { + "type": "integer", + "required": false, + "default": 20, + "min": 1, + "max": 100, + "description": "페이지 크기" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "exampleUrl": "/social/upload/history?platform=youtube&status=completed&page=1&size=20" + }, + "response": { + "success": { + "status": 200, + "body": { + "items": [ + { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "title": "나의 첫 영상", + "platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00" + } + ], + "total": 1, + "page": 1, + "size": 20 + } + } + } + }, + "retry": { + "name": "업로드 재시도", + "method": "POST", + "url": "/social/upload/{upload_id}/retry", + "description": "실패한 업로드를 재시도합니다.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "upload_id": 456, + "platform": "youtube", + "status": "pending", + "message": "업로드 재시도가 요청되었습니다." + } + }, + "error": { + "400": { + "detail": "실패하거나 취소된 업로드만 재시도할 수 있습니다." + }, + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + } + }, + "cancel": { + "name": "업로드 취소", + "method": "DELETE", + "url": "/social/upload/{upload_id}", + "description": "대기 중인 업로드를 취소합니다.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "message": "업로드가 취소되었습니다." + } + }, + "error": { + "400": { + "detail": "대기 중인 업로드만 취소할 수 있습니다." + }, + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + } + } + } + }, + "frontendPages": { + "required": [ + { + "path": "/social/connect/success", + "description": "OAuth 연동 성공 후 리다이렉트되는 페이지", + "queryParams": ["platform", "account_id"], + "action": "연동 성공 메시지 표시 후 설정 페이지로 이동" + }, + { + "path": "/social/connect/error", + "description": "OAuth 연동 실패 시 리다이렉트되는 페이지", + "queryParams": ["platform", "error"], + "action": "에러 메시지 표시 및 재시도 옵션 제공" + } + ], + "recommended": [ + { + "path": "/settings/social", + "description": "소셜 계정 관리 페이지", + "features": ["연동된 계정 목록", "연동/해제 버튼", "업로드 이력"] + } + ] + }, + "flowExamples": { + "connectYouTube": { + "description": "YouTube 계정 연동 플로우", + "steps": [ + { + "step": 1, + "action": "GET /social/oauth/youtube/connect 호출", + "result": "auth_url 반환" + }, + { + "step": 2, + "action": "window.location.href = auth_url", + "result": "Google 로그인 페이지로 이동" + }, + { + "step": 3, + "action": "사용자가 권한 승인", + "result": "백엔드 콜백 URL로 자동 리다이렉트" + }, + { + "step": 4, + "action": "백엔드가 토큰 교환 후 프론트엔드로 리다이렉트", + "result": "/social/connect/success?platform=youtube&account_id=1" + }, + { + "step": 5, + "action": "연동 성공 처리 후 GET /social/oauth/accounts 호출", + "result": "연동된 계정 정보 표시" + } + ] + }, + "uploadVideo": { + "description": "YouTube 영상 업로드 플로우", + "steps": [ + { + "step": 1, + "action": "POST /social/upload 호출", + "request": { + "video_id": 123, + "platform": "youtube", + "title": "영상 제목", + "privacy_status": "private" + }, + "result": "upload_id 반환" + }, + { + "step": 2, + "action": "GET /social/upload/{upload_id}/status 폴링 (3초 간격)", + "result": "status, upload_progress 확인" + }, + { + "step": 3, + "action": "status === 'completed' 확인", + "result": "platform_url로 YouTube 링크 표시" + } + ], + "pollingCode": "setInterval(() => checkUploadStatus(uploadId), 3000)" + } + } +} diff --git a/index.css b/index.css index fb67beb..cece0cd 100644 --- a/index.css +++ b/index.css @@ -1767,6 +1767,108 @@ letter-spacing: -0.006em; } +/* Social Card - Text Layout */ +.completion-social-text { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.completion-social-channel { + font-size: 0.75rem; + font-weight: 400; + color: #94FBE0; + line-height: 1.19; + letter-spacing: -0.006em; +} + +/* Connected Account Display */ +.completion-social-thumbnail { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 2px solid #94FBE0; +} + +.completion-social-channel-name { + font-size: 0.875rem; + font-weight: 600; + color: #FFFFFF; + line-height: 1.2; +} + +.completion-social-platform { + font-size: 0.7rem; + font-weight: 400; + color: rgba(255, 255, 255, 0.5); + line-height: 1.2; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Social Card States */ +.completion-social-card.connected { + border: 1px solid rgba(166, 255, 234, 0.3); + background-color: rgba(0, 34, 36, 0.8); +} + +.completion-social-card.selected { + border: 1px solid #94FBE0; + background-color: rgba(148, 251, 224, 0.1); +} + +/* Social Card Status */ +.completion-social-status { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + white-space: nowrap; +} + +.completion-social-status.connected { + color: #94FBE0; + background-color: rgba(148, 251, 224, 0.15); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.completion-social-status.connecting { + color: #fbbf24; + background-color: rgba(251, 191, 36, 0.15); +} + +.completion-social-status.not-connected { + color: #9ca3af; + background-color: rgba(156, 163, 175, 0.15); +} + +/* Social Card Actions */ +.completion-social-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.completion-social-disconnect { + font-size: 0.75rem; + font-weight: 500; + color: #f87171; + background: transparent; + border: none; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all var(--transition-normal); +} + +.completion-social-disconnect:hover { + background-color: rgba(248, 113, 113, 0.15); +} + /* Completion Buttons */ .btn-completion-deploy { width: 100%; @@ -4421,6 +4523,929 @@ } } +/* Dashboard Header Extended */ +.dashboard-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; + margin-left: 2.5rem; +} + +@media (min-width: 768px) { + .dashboard-header-row { + margin-bottom: 1rem; + margin-left: 0; + align-items: center; + } +} + +.dashboard-last-updated { + font-size: 8px; + color: var(--color-text-gray-500); + display: none; +} + +@media (min-width: 640px) { + .dashboard-last-updated { + display: block; + font-size: 9px; + } +} + +/* Stats Grid 5 Columns */ +.stats-grid-5 { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +@media (min-width: 640px) { + .stats-grid-5 { + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; + } +} + +@media (min-width: 768px) { + .stats-grid-5 { + grid-template-columns: repeat(5, 1fr); + } +} + +/* Stat Trend with Direction */ +.stat-trend-wrapper { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.stat-trend-icon { + width: 10px; + height: 10px; +} + +@media (min-width: 640px) { + .stat-trend-icon { + width: 12px; + height: 12px; + } +} + +.stat-trend.up { + color: var(--color-mint); +} + +.stat-trend.down { + color: #f87171; +} + +/* Dashboard Section */ +.dashboard-section { + margin-bottom: 0.75rem; +} + +@media (min-width: 768px) { + .dashboard-section { + margin-bottom: 1rem; + } +} + +.dashboard-section-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-white); + margin-bottom: 0.5rem; +} + +@media (min-width: 768px) { + .dashboard-section-title { + font-size: var(--text-base); + margin-bottom: 0.75rem; + } +} + +/* Chart Legend Dual (for YoY comparison) */ +.chart-legend-dual { + display: flex; + gap: 1rem; + align-items: center; +} + +.chart-legend-item { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.chart-legend-line { + width: 1rem; + height: 2px; + border-radius: var(--radius-full); +} + +.chart-legend-line.solid { + background-color: var(--color-mint); +} + +.chart-legend-line.dashed { + background: repeating-linear-gradient( + 90deg, + var(--color-purple) 0px, + var(--color-purple) 3px, + transparent 3px, + transparent 6px + ); +} + +/* Platform Tabs */ +.platform-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +@media (min-width: 768px) { + .platform-tabs { + margin-bottom: 1rem; + } +} + +.platform-tab { + padding: 0.5rem 0.75rem; + border-radius: var(--radius-full); + border: 1px solid var(--color-border-gray-700); + background-color: transparent; + color: var(--color-text-gray-400); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + transition: all var(--transition-normal); + display: flex; + align-items: center; + gap: 0.375rem; +} + +@media (min-width: 640px) { + .platform-tab { + padding: 0.5rem 1rem; + font-size: var(--text-sm); + gap: 0.5rem; + } +} + +.platform-tab:hover { + border-color: var(--color-border-gray-600); + color: var(--color-text-gray-300); +} + +.platform-tab.active { + border-color: var(--color-mint); + background-color: rgba(166, 255, 234, 0.1); + color: var(--color-mint); +} + +.platform-tab-icon { + width: 0.875rem; + height: 0.875rem; +} + +@media (min-width: 640px) { + .platform-tab-icon { + width: 1rem; + height: 1rem; + } +} + +/* Platform Metrics Grid */ +.platform-metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@media (min-width: 640px) { + .platform-metrics-grid { + gap: 0.75rem; + } +} + +@media (min-width: 768px) { + .platform-metrics-grid { + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + } +} + +/* Metric Card */ +.metric-card { + background-color: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: 0.75rem; + border: 1px solid var(--color-border-white-5); + display: flex; + flex-direction: column; + gap: 0.375rem; + box-shadow: var(--shadow-xl); + transition: transform var(--transition-normal); +} + +@media (min-width: 640px) { + .metric-card { + padding: 1rem; + border-radius: var(--radius-2xl); + gap: 0.5rem; + } +} + +.metric-card:hover { + transform: scale(1.02); +} + +.metric-card-header { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.metric-card-icon { + width: 1rem; + height: 1rem; + color: var(--color-text-gray-400); +} + +@media (min-width: 640px) { + .metric-card-icon { + width: 1.25rem; + height: 1.25rem; + } +} + +.metric-card-label { + font-size: var(--text-xs); + color: var(--color-text-gray-400); + font-weight: 500; +} + +@media (min-width: 640px) { + .metric-card-label { + font-size: var(--text-sm); + } +} + +.metric-card-value { + font-size: var(--text-xl); + font-weight: 700; + color: var(--color-text-white); + display: flex; + align-items: baseline; + gap: 0.25rem; +} + +@media (min-width: 640px) { + .metric-card-value { + font-size: var(--text-2xl); + } +} + +@media (min-width: 768px) { + .metric-card-value { + font-size: 1.75rem; + } +} + +.metric-card-unit { + font-size: var(--text-xs); + color: var(--color-text-gray-500); + font-weight: 400; +} + +@media (min-width: 640px) { + .metric-card-unit { + font-size: var(--text-sm); + } +} + +.metric-card-trend { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 9px; + font-weight: 500; +} + +@media (min-width: 640px) { + .metric-card-trend { + font-size: var(--text-xs); + } +} + +.metric-card-trend.up { + color: var(--color-mint); +} + +.metric-card-trend.down { + color: #f87171; +} + +.metric-card-trend-icon { + width: 10px; + height: 10px; +} + +@media (min-width: 640px) { + .metric-card-trend-icon { + width: 12px; + height: 12px; + } +} + +/* YoY Chart */ +.yoy-chart-card { + background-color: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: 0.75rem; + border: 1px solid var(--color-border-white-5); + box-shadow: var(--shadow-xl); + margin-bottom: 0.75rem; +} + +@media (min-width: 640px) { + .yoy-chart-card { + padding: 1rem; + border-radius: var(--radius-2xl); + } +} + +@media (min-width: 768px) { + .yoy-chart-card { + padding: 1.5rem; + margin-bottom: 1rem; + } +} + +.yoy-chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +@media (min-width: 768px) { + .yoy-chart-header { + margin-bottom: 1rem; + } +} + +.yoy-chart-container { + position: relative; + width: 100%; + height: 180px; +} + +@media (min-width: 640px) { + .yoy-chart-container { + height: 220px; + } +} + +@media (min-width: 768px) { + .yoy-chart-container { + height: 280px; + } +} + +.yoy-chart-svg { + width: 100%; + height: 100%; +} + +.yoy-chart-xaxis { + display: flex; + justify-content: space-between; + margin-top: 0.5rem; + padding: 0; + font-size: 8px; + color: var(--color-text-gray-500); + font-weight: 500; +} + +@media (min-width: 640px) { + .yoy-chart-xaxis { + font-size: 9px; + margin-top: 0.75rem; + } +} + +@media (min-width: 768px) { + .yoy-chart-xaxis { + font-size: 10px; + } +} + +/* Chart Wrapper for Tooltip */ +.yoy-chart-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +/* Chart Tooltip */ +.chart-tooltip { + position: absolute; + transform: translate(-50%, -100%); + margin-top: -12px; + background-color: rgba(28, 42, 46, 0.95); + border: 1px solid var(--color-border-white-10); + border-radius: var(--radius-lg); + padding: 0.75rem; + min-width: 120px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + pointer-events: none; + z-index: 100; + animation: tooltipFadeIn 0.2s ease; +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translate(-50%, -90%); + } + to { + opacity: 1; + transform: translate(-50%, -100%); + } +} + +.chart-tooltip-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-white); + margin-bottom: 0.5rem; + text-align: center; +} + +.chart-tooltip-row { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.25rem; +} + +.chart-tooltip-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); +} + +.chart-tooltip-dot.mint { + background-color: var(--color-mint); +} + +.chart-tooltip-dot.purple { + background-color: var(--color-purple); +} + +.chart-tooltip-label { + font-size: var(--text-xs); + color: var(--color-text-gray-400); + flex: 1; +} + +.chart-tooltip-value { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-white); +} + +.chart-tooltip-change { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border-white-5); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-mint); + text-align: center; +} + +/* Chart Animations */ +.chart-line-animated { + stroke-dasharray: 2000; + stroke-dashoffset: 2000; + transition: stroke-dashoffset 1.5s ease-out; +} + +.chart-line-animated.visible { + stroke-dashoffset: 0; +} + +.chart-area-animated { + opacity: 0; + transition: opacity 0.8s ease-out 0.5s; +} + +.chart-area-animated.visible { + opacity: 1; +} + +.chart-point-animated { + opacity: 0; + transform-origin: center; + transition: opacity 0.3s ease; +} + +.chart-point-animated.visible { + opacity: 1; +} + +/* Platform Section Card */ +.platform-section-card { + background-color: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: 0.75rem; + border: 1px solid var(--color-border-white-5); + box-shadow: var(--shadow-xl); +} + +@media (min-width: 640px) { + .platform-section-card { + padding: 1rem; + border-radius: var(--radius-2xl); + } +} + +@media (min-width: 768px) { + .platform-section-card { + padding: 1.5rem; + } +} + +.platform-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +@media (min-width: 768px) { + .platform-section-header { + margin-bottom: 1rem; + } +} + +/* Stats Grid 8 Columns */ +.stats-grid-8 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@media (min-width: 640px) { + .stats-grid-8 { + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + } +} + +@media (min-width: 1024px) { + .stats-grid-8 { + grid-template-columns: repeat(8, 1fr); + } +} + +/* Platform Metrics Grid 8 Columns */ +.platform-metrics-grid-8 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@media (min-width: 640px) { + .platform-metrics-grid-8 { + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + } +} + +@media (min-width: 1024px) { + .platform-metrics-grid-8 { + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + } +} + +/* Dashboard Two Column Layout */ +.dashboard-two-column { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +@media (min-width: 1024px) { + .dashboard-two-column { + grid-template-columns: 2fr 1fr; + gap: 1rem; + margin-bottom: 1rem; + } +} + +/* Top Content Card */ +.top-content-card { + background-color: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: 0.75rem; + border: 1px solid var(--color-border-white-5); + box-shadow: var(--shadow-xl); +} + +@media (min-width: 640px) { + .top-content-card { + padding: 1rem; + border-radius: var(--radius-2xl); + } +} + +@media (min-width: 768px) { + .top-content-card { + padding: 1.5rem; + } +} + +.top-content-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.top-content-item { + display: flex; + gap: 0.75rem; + padding: 0.5rem; + border-radius: var(--radius-lg); + background-color: var(--color-bg-card-inner); + transition: transform var(--transition-normal); +} + +.top-content-item:hover { + transform: scale(1.01); +} + +.top-content-thumbnail { + position: relative; + width: 80px; + height: 45px; + border-radius: var(--radius-md); + overflow: hidden; + flex-shrink: 0; +} + +@media (min-width: 640px) { + .top-content-thumbnail { + width: 100px; + height: 56px; + } +} + +.top-content-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.top-content-platform-badge { + position: absolute; + bottom: 4px; + right: 4px; + background-color: rgba(0, 0, 0, 0.7); + border-radius: var(--radius-sm); + padding: 2px; + display: flex; + align-items: center; + justify-content: center; +} + +.top-content-platform-icon { + width: 12px; + height: 12px; + color: white; +} + +.top-content-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; +} + +.top-content-title { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-white); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (min-width: 640px) { + .top-content-title { + font-size: var(--text-sm); + } +} + +.top-content-stats { + display: flex; + gap: 0.75rem; +} + +.top-content-stat { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 10px; + color: var(--color-text-gray-400); +} + +@media (min-width: 640px) { + .top-content-stat { + font-size: var(--text-xs); + } +} + +.top-content-stat svg { + color: var(--color-text-gray-500); +} + +.top-content-date { + font-size: 9px; + color: var(--color-text-gray-500); +} + +@media (min-width: 640px) { + .top-content-date { + font-size: 10px; + } +} + +/* Audience Section */ +.audience-section { + margin-bottom: 0.75rem; +} + +@media (min-width: 768px) { + .audience-section { + margin-bottom: 1rem; + } +} + +.audience-cards { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; +} + +@media (min-width: 768px) { + .audience-cards { + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + } +} + +.audience-card { + background-color: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: 1rem; + border: 1px solid var(--color-border-white-5); + box-shadow: var(--shadow-xl); +} + +@media (min-width: 640px) { + .audience-card { + padding: 1.25rem; + border-radius: var(--radius-2xl); + } +} + +.audience-card-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-gray-300); + margin-bottom: 1rem; +} + +/* Audience Bar Chart */ +.audience-bar-chart { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.audience-bar-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.audience-bar-label { + font-size: var(--text-xs); + color: var(--color-text-gray-400); + width: 40px; + flex-shrink: 0; +} + +.audience-bar-track { + flex: 1; + height: 8px; + background-color: var(--color-bg-card-inner); + border-radius: var(--radius-full); + overflow: hidden; +} + +.audience-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-mint), var(--color-purple)); + border-radius: var(--radius-full); + transition: width 0.5s ease-out; +} + +.audience-bar-value { + font-size: var(--text-xs); + color: var(--color-text-white); + width: 36px; + text-align: right; + flex-shrink: 0; +} + +/* Gender Chart */ +.gender-chart { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.gender-chart-bars { + display: flex; + height: 32px; + border-radius: var(--radius-full); + overflow: hidden; +} + +.gender-bar { + display: flex; + align-items: center; + justify-content: center; + transition: width 0.5s ease-out; +} + +.gender-bar span { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-bg-dark); +} + +.gender-bar.male { + background: linear-gradient(90deg, #60a5fa, #3b82f6); +} + +.gender-bar.female { + background: linear-gradient(90deg, #f472b6, #ec4899); +} + +.gender-chart-labels { + display: flex; + justify-content: space-between; +} + +.gender-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: var(--text-xs); + color: var(--color-text-gray-400); +} + +.gender-label::before { + content: ''; + width: 8px; + height: 8px; + border-radius: var(--radius-full); +} + +.gender-label.male::before { + background-color: #3b82f6; +} + +.gender-label.female::before { + background-color: #ec4899; +} + /* ===================================================== Business Settings Components ===================================================== */ @@ -6417,3 +7442,137 @@ opacity: 0.5; cursor: not-allowed; } + +/* ============================================ + Social Connect Pages + ============================================ */ + +.social-connect-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: #0d1416; + padding: 1rem; +} + +.social-connect-card { + background-color: #0a1a1c; + border-radius: 16px; + padding: 3rem; + max-width: 400px; + width: 100%; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.social-connect-card.success { + border-color: rgba(148, 251, 224, 0.3); +} + +.social-connect-card.error { + border-color: rgba(248, 113, 113, 0.3); +} + +.social-connect-icon { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; +} + +.social-connect-icon.success { + background-color: rgba(148, 251, 224, 0.15); + color: #94FBE0; +} + +.social-connect-icon.error { + background-color: rgba(248, 113, 113, 0.15); + color: #f87171; +} + +.social-connect-profile-image { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + margin: 0 auto 1.5rem; + border: 3px solid #94FBE0; +} + +.social-connect-account-info { + margin-bottom: 1.5rem; +} + +.social-connect-channel-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.875rem; + background-color: rgba(148, 251, 224, 0.15); + color: #94FBE0; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} + +.social-connect-title { + font-size: 1.5rem; + font-weight: 600; + color: #FFFFFF; + margin-bottom: 0.75rem; +} + +.social-connect-message { + font-size: 1rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 0.5rem; +} + +.social-connect-detail { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 1.5rem; +} + +.social-connect-countdown { + font-size: 0.875rem; + color: #94FBE0; + margin-bottom: 1rem; +} + +.social-connect-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.social-connect-button { + width: 100%; + padding: 0.875rem 1.5rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + background-color: #94FBE0; + color: #0d1416; +} + +.social-connect-button:hover { + background-color: #7fe6cb; +} + +.social-connect-button.secondary { + background-color: transparent; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.social-connect-button.secondary:hover { + background-color: rgba(255, 255, 255, 0.1); +} diff --git a/src/App.tsx b/src/App.tsx index 7e12feb..1b55437 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,8 @@ import LoadingSection from './pages/Analysis/LoadingSection'; import AnalysisResultSection from './pages/Analysis/AnalysisResultSection'; import LoginSection from './pages/Login/LoginSection'; import GenerationFlow from './pages/Dashboard/GenerationFlow'; +import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; +import SocialConnectError from './pages/Social/SocialConnectError'; import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; import { CrawlingResponse } from './types/api'; @@ -328,6 +330,17 @@ const App: React.FC = () => { setViewMode('landing'); }; + // Social OAuth 콜백 페이지 처리 + const pathname = window.location.pathname; + + if (pathname === '/social/connect/success') { + return ; + } + + if (pathname === '/social/connect/error') { + return ; + } + // 카카오 콜백 처리 중 로딩 화면 표시 if (isProcessingCallback) { return ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 72a1fc1..b1cedef 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -55,7 +55,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome }) => }; const menuItems = [ - { id: '대시보드', label: '대시보드', disabled: true, icon: }, + { id: '대시보드', label: '대시보드', disabled: false, icon: }, { id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: }, { id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: }, { id: '에셋 관리', label: '에셋 관리', disabled: true, icon: }, diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 660037e..fb2ee4f 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; -import { generateVideo, waitForVideoComplete } from '../../utils/api'; +import { generateVideo, waitForVideoComplete, getYouTubeConnectUrl, getSocialAccounts, disconnectSocialAccount } from '../../utils/api'; +import { SocialAccount } from '../../types/api'; interface CompletionContentProps { onBack: () => void; @@ -12,6 +13,7 @@ interface CompletionContentProps { type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error'; const VIDEO_STORAGE_KEY = 'castad_video_generation'; +const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 (만료 없음) const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분 interface SavedVideoState { @@ -39,6 +41,11 @@ const CompletionContent: React.FC = ({ const videoRef = useRef(null); const hasStartedGeneration = useRef(false); + // YouTube 연결 상태 + const [youtubeAccount, setYoutubeAccount] = useState(null); + const [isYoutubeConnecting, setIsYoutubeConnecting] = useState(false); + const [youtubeError, setYoutubeError] = useState(null); + // Notify parent of video status changes useEffect(() => { if (onVideoStatusChange) { @@ -63,10 +70,33 @@ const CompletionContent: React.FC = ({ timestamp: Date.now(), }; localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data)); + + // 완료된 경우 별도의 만료 없는 키에도 저장 + if (status === 'complete' && url) { + const completeData = { + songTaskId: currentSongTaskId, + videoUrl: url, + completedAt: Date.now(), + }; + localStorage.setItem(VIDEO_COMPLETE_KEY, JSON.stringify(completeData)); + console.log('[Video] Saved complete state:', completeData); + } }; const clearStorage = () => { localStorage.removeItem(VIDEO_STORAGE_KEY); + // 주의: VIDEO_COMPLETE_KEY는 여기서 삭제하지 않음 (명시적으로만 삭제) + }; + + // 완료된 영상 정보 로드 (만료 없음) + const loadCompleteVideo = (): { songTaskId: string; videoUrl: string } | null => { + try { + const saved = localStorage.getItem(VIDEO_COMPLETE_KEY); + if (!saved) return null; + return JSON.parse(saved); + } catch { + return null; + } }; const loadFromStorage = (): SavedVideoState | null => { @@ -200,6 +230,19 @@ const CompletionContent: React.FC = ({ // 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작 useEffect(() => { + if (!songTaskId) return; + + // 1. 먼저 완료된 영상 정보 확인 (만료 없음) + const completeVideo = loadCompleteVideo(); + if (completeVideo && completeVideo.songTaskId === songTaskId && completeVideo.videoUrl) { + console.log('[Video] Restored from complete storage:', completeVideo); + setVideoUrl(completeVideo.videoUrl); + setVideoStatus('complete'); + hasStartedGeneration.current = true; + return; // 완료된 영상이 있으면 더 이상 진행하지 않음 + } + + // 2. 일반 저장소에서 상태 확인 (30분 만료) const savedState = loadFromStorage(); // 저장된 상태가 있고, 같은 songTaskId인 경우 @@ -216,12 +259,118 @@ const CompletionContent: React.FC = ({ hasStartedGeneration.current = true; pollVideoStatus(savedState.videoTaskId, savedState.songTaskId); } - } else if (songTaskId && !hasStartedGeneration.current) { + } else if (!hasStartedGeneration.current) { // 새로운 영상 생성 시작 startVideoGeneration(); } }, [songTaskId]); + // localStorage에서 방금 연결된 계정 정보 확인 + const SOCIAL_CONNECTED_KEY = 'castad_social_connected'; + + const checkLocalStorageForConnectedAccount = () => { + try { + const saved = localStorage.getItem(SOCIAL_CONNECTED_KEY); + if (saved) { + const connectedAccount = JSON.parse(saved); + console.log('[YouTube] Found connected account in localStorage:', connectedAccount); + + if (connectedAccount.platform === 'youtube') { + // localStorage에서 계정 정보로 즉시 UI 업데이트 + setYoutubeAccount({ + id: parseInt(connectedAccount.account_id, 10), + platform: 'youtube', + platform_user_id: connectedAccount.account_id, + platform_username: connectedAccount.channel_name, + display_name: connectedAccount.channel_name, + is_active: true, + connected_at: connectedAccount.connected_at, + profile_image: connectedAccount.profile_image, + }); + + // localStorage에서 제거 (일회성 사용) + localStorage.removeItem(SOCIAL_CONNECTED_KEY); + return true; + } + } + } catch (error) { + console.error('[YouTube] Failed to parse localStorage:', error); + } + return false; + }; + + // YouTube 연결 상태 확인 (API 호출 - /social/oauth/accounts) + const checkYoutubeConnection = async () => { + try { + console.log('[YouTube] Checking connection status from API...'); + const response = await getSocialAccounts(); + console.log('[YouTube] Accounts response:', response); + + // accounts 배열에서 youtube 플랫폼 찾기 + const youtubeAcc = response.accounts?.find(acc => acc.platform === 'youtube' && acc.is_active); + + if (youtubeAcc) { + setYoutubeAccount(youtubeAcc); + console.log('[YouTube] Account connected:', youtubeAcc.display_name); + } else { + setYoutubeAccount(null); + console.log('[YouTube] No YouTube account connected'); + } + } catch (error) { + console.error('[YouTube] Failed to check connection:', error); + // API 에러 시에도 localStorage에서 확인한 값 유지 + } + }; + + useEffect(() => { + // 먼저 localStorage에서 방금 연결된 계정 확인 + const foundInLocalStorage = checkLocalStorageForConnectedAccount(); + + // localStorage에 없으면 API로 확인 + if (!foundInLocalStorage) { + checkYoutubeConnection(); + } else { + // localStorage에서 찾았어도 API로 최신 정보 갱신 (백그라운드) + checkYoutubeConnection(); + } + }, []); + + // YouTube 연결 핸들러 + const handleYoutubeConnect = async () => { + if (youtubeAccount) { + // 이미 연결된 경우 선택/해제만 토글 + toggleSocial('Youtube'); + return; + } + + setIsYoutubeConnecting(true); + setYoutubeError(null); + + try { + const response = await getYouTubeConnectUrl(); + // OAuth URL로 리다이렉트 + window.location.href = response.auth_url; + } catch (error) { + console.error('YouTube connect failed:', error); + setYoutubeError('YouTube 연결에 실패했습니다.'); + setIsYoutubeConnecting(false); + } + }; + + // YouTube 연결 해제 핸들러 + const handleYoutubeDisconnect = async () => { + if (!youtubeAccount) return; + + try { + await disconnectSocialAccount('youtube'); + setYoutubeAccount(null); + setSelectedSocials(prev => prev.filter(s => s !== 'Youtube')); + } catch (error) { + console.error('YouTube disconnect failed:', error); + setYoutubeError('연결 해제에 실패했습니다.'); + } + }; + const toggleSocial = (id: string) => { setSelectedSocials(prev => prev.includes(id) @@ -273,9 +422,16 @@ const CompletionContent: React.FC = ({ }; const socials = [ - { id: 'Youtube', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-youtube.png' }, - { id: 'Instagram', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-instagram.png' }, - { id: 'Facebook', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-facebook.png' }, + { + id: 'Youtube', + logo: '/assets/images/social-youtube.png', + connected: !!youtubeAccount, + channelName: youtubeAccount?.display_name || null, + thumbnailUrl: youtubeAccount?.profile_image || null, + isConnecting: isYoutubeConnecting, + }, + { id: 'Instagram', logo: '/assets/images/social-instagram.png', connected: false, channelName: null, thumbnailUrl: null, isConnecting: false }, + { id: 'Facebook', logo: '/assets/images/social-facebook.png', connected: false, channelName: null, thumbnailUrl: null, isConnecting: false }, ]; const isLoading = videoStatus === 'generating' || videoStatus === 'polling'; @@ -388,20 +544,72 @@ const CompletionContent: React.FC = ({
{socials.map(social => { const isSelected = selectedSocials.includes(social.id); + const isYoutube = social.id === 'Youtube'; + return (
videoStatus === 'complete' && toggleSocial(social.id)} - className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''}`} + onClick={() => { + if (videoStatus !== 'complete') return; + if (isYoutube) { + handleYoutubeConnect(); + } else { + // Instagram, Facebook은 아직 미구현 + } + }} + className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''} ${social.connected ? 'connected' : ''} ${isSelected ? 'selected' : ''}`} >
- {social.id} - {social.id} + {/* 연결된 경우 채널 썸네일 표시, 아니면 플랫폼 로고 */} + {social.connected && social.thumbnailUrl ? ( + {social.channelName + ) : ( + {social.id} + )} +
+ {social.connected && social.channelName ? ( + <> + {social.channelName} + {social.id} + + ) : ( + {social.id} + )} +
- {social.email} + {social.isConnecting ? ( + 연결 중... + ) : social.connected ? ( +
+ + + + + 인증됨 + + {isYoutube && ( + + )} +
+ ) : ( + + {isYoutube ? '계정 연결' : '준비 중'} + + )}
); })} + {youtubeError && ( +

{youtubeError}

+ )}
diff --git a/src/pages/Dashboard/DashboardContent.tsx b/src/pages/Dashboard/DashboardContent.tsx index ff1cd77..a958540 100755 --- a/src/pages/Dashboard/DashboardContent.tsx +++ b/src/pages/Dashboard/DashboardContent.tsx @@ -1,103 +1,690 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; -const StatCard: React.FC<{ label: string; value: string; trend: string }> = ({ label, value, trend }) => ( +// ===================================================== +// Types +// ===================================================== + +interface ContentMetric { + id: string; + label: string; + labelEn: string; + value: string; + trend: number; + trendDirection: 'up' | 'down'; +} + +interface MonthlyData { + month: string; + thisYear: number; + lastYear: number; +} + +interface PlatformMetric { + id: string; + label: string; + value: string; + unit?: string; + trend: number; + trendDirection: 'up' | 'down'; +} + +interface PlatformData { + platform: 'youtube' | 'instagram'; + displayName: string; + metrics: PlatformMetric[]; +} + +interface TopContent { + id: string; + title: string; + thumbnail: string; + platform: 'youtube' | 'instagram'; + views: string; + engagement: string; + date: string; +} + +interface AudienceData { + ageGroups: { label: string; percentage: number }[]; + gender: { male: number; female: number }; + topRegions: { region: string; percentage: number }[]; +} + +// ===================================================== +// Mock Data +// ===================================================== + +const CONTENT_METRICS: ContentMetric[] = [ + { id: 'impressions', label: '총 노출수', labelEn: 'IMPRESSIONS', value: '2.4M', trend: 12.5, trendDirection: 'up' }, + { id: 'reach', label: '도달', labelEn: 'REACH', value: '1.8M', trend: 9.2, trendDirection: 'up' }, + { id: 'likes', label: '좋아요', labelEn: 'LIKES', value: '158.2K', trend: 8.3, trendDirection: 'up' }, + { id: 'comments', label: '댓글', labelEn: 'COMMENTS', value: '24.9K', trend: 2.1, trendDirection: 'down' }, + { id: 'shares', label: '공유', labelEn: 'SHARES', value: '8.4K', trend: 15.7, trendDirection: 'up' }, + { id: 'saves', label: '저장', labelEn: 'SAVES', value: '12.3K', trend: 22.4, trendDirection: 'up' }, + { id: 'engagement', label: '참여율', labelEn: 'ENGAGEMENT', value: '4.8%', trend: 0.5, trendDirection: 'up' }, + { id: 'content', label: '콘텐츠', labelEn: 'CONTENT', value: '127', trend: 4, trendDirection: 'up' }, +]; + +const MONTHLY_DATA: MonthlyData[] = [ + { month: '1월', thisYear: 180000, lastYear: 145000 }, + { month: '2월', thisYear: 195000, lastYear: 158000 }, + { month: '3월', thisYear: 210000, lastYear: 172000 }, + { month: '4월', thisYear: 185000, lastYear: 168000 }, + { month: '5월', thisYear: 240000, lastYear: 195000 }, + { month: '6월', thisYear: 275000, lastYear: 210000 }, + { month: '7월', thisYear: 320000, lastYear: 235000 }, + { month: '8월', thisYear: 295000, lastYear: 248000 }, + { month: '9월', thisYear: 310000, lastYear: 262000 }, + { month: '10월', thisYear: 285000, lastYear: 255000 }, + { month: '11월', thisYear: 340000, lastYear: 278000 }, + { month: '12월', thisYear: 380000, lastYear: 295000 }, +]; + +const PLATFORM_DATA: PlatformData[] = [ + { + platform: 'youtube', + displayName: 'YouTube', + metrics: [ + { id: 'views', label: '조회수', value: '1.25M', trend: 18.2, trendDirection: 'up' }, + { id: 'watchTime', label: '시청 시간', value: '4,820', unit: '시간', trend: 12.5, trendDirection: 'up' }, + { id: 'avgViewDuration', label: '평균 시청 시간', value: '3:24', trend: 5.2, trendDirection: 'up' }, + { id: 'subscribers', label: '구독자', value: '45.2K', trend: 5.8, trendDirection: 'up' }, + { id: 'newSubscribers', label: '신규 구독자', value: '1.2K', trend: 12.3, trendDirection: 'up' }, + { id: 'engagement', label: '참여율', value: '4.8', unit: '%', trend: 0.3, trendDirection: 'up' }, + { id: 'ctr', label: '클릭률 (CTR)', value: '6.2', unit: '%', trend: 1.1, trendDirection: 'up' }, + { id: 'revenue', label: '예상 수익', value: '₩2.4M', trend: 8.5, trendDirection: 'up' }, + ], + }, + { + platform: 'instagram', + displayName: 'Instagram', + metrics: [ + { id: 'reach', label: '도달', value: '892K', trend: 22.4, trendDirection: 'up' }, + { id: 'impressions', label: '노출', value: '1.58M', trend: 15.1, trendDirection: 'up' }, + { id: 'profileVisits', label: '프로필 방문', value: '28.4K', trend: 18.7, trendDirection: 'up' }, + { id: 'followers', label: '팔로워', value: '28.5K', trend: 8.2, trendDirection: 'up' }, + { id: 'newFollowers', label: '신규 팔로워', value: '892', trend: 15.6, trendDirection: 'up' }, + { id: 'storyViews', label: '스토리 조회', value: '156K', trend: 3.2, trendDirection: 'down' }, + { id: 'reelPlays', label: '릴스 재생', value: '423K', trend: 45.2, trendDirection: 'up' }, + { id: 'websiteClicks', label: '웹사이트 클릭', value: '3.2K', trend: 11.8, trendDirection: 'up' }, + ], + }, +]; + +const TOP_CONTENT: TopContent[] = [ + { id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: '125.4K', engagement: '8.2%', date: '2025.01.15' }, + { id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: '89.2K', engagement: '12.5%', date: '2025.01.22' }, + { id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: '67.8K', engagement: '6.4%', date: '2025.01.08' }, + { id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: '54.3K', engagement: '15.8%', date: '2025.01.28' }, +]; + +const AUDIENCE_DATA: AudienceData = { + ageGroups: [ + { label: '18-24', percentage: 12 }, + { label: '25-34', percentage: 35 }, + { label: '35-44', percentage: 28 }, + { label: '45-54', percentage: 18 }, + { label: '55+', percentage: 7 }, + ], + gender: { male: 42, female: 58 }, + topRegions: [ + { region: '서울', percentage: 32 }, + { region: '경기', percentage: 24 }, + { region: '부산', percentage: 12 }, + { region: '인천', percentage: 8 }, + { region: '대구', percentage: 6 }, + ], +}; + +// ===================================================== +// Animation Components +// ===================================================== + +interface AnimatedItemProps { + children: React.ReactNode; + index: number; + baseDelay?: number; + className?: string; +} + +const AnimatedItem: React.FC = ({ children, index, baseDelay = 0, className = '' }) => { + const [isVisible, setIsVisible] = useState(false); + const delay = baseDelay + index * 80; + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + return ( +
+ {children} +
+ ); +}; + +interface AnimatedSectionProps { + children: React.ReactNode; + delay?: number; + className?: string; +} + +const AnimatedSection: React.FC = ({ children, delay = 0, className = '' }) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + return ( +
+ {children} +
+ ); +}; + +// ===================================================== +// UI Components +// ===================================================== + +const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => ( + + {direction === 'up' ? ( + + ) : ( + + )} + +); + +const StatCard: React.FC = ({ labelEn, value, trend, trendDirection }) => (
- {label} + {labelEn}

{value}

- {trend} +
+ + + {trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}% + +
); -const DashboardContent: React.FC = () => { +const MetricCard: React.FC = ({ label, value, unit, trend, trendDirection }) => ( +
+
+ {label} +
+
+ {value} + {unit && {unit}} +
+
+ + {trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}% +
+
+); + +// ===================================================== +// Chart Component with Tooltip +// ===================================================== + +interface TooltipData { + x: number; + y: number; + month: string; + thisYear: number; + lastYear: number; +} + +const formatNumber = (num: number): string => { + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; + return num.toString(); +}; + +const YearOverYearChart: React.FC<{ data: MonthlyData[] }> = ({ data }) => { + const [tooltip, setTooltip] = useState(null); + const [isAnimated, setIsAnimated] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsAnimated(true), 500); + return () => clearTimeout(timer); + }, []); + + const width = 1000; + const height = 300; + const padding = { top: 20, right: 20, bottom: 10, left: 20 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + const allValues = data.flatMap(d => [d.thisYear, d.lastYear]); + const maxValue = Math.max(...allValues); + const minValue = Math.min(...allValues); + const yPadding = (maxValue - minValue) * 0.15; + const yMax = maxValue + yPadding; + const yMin = Math.max(0, minValue - yPadding); + + const getX = (index: number) => padding.left + (index / (data.length - 1)) * chartWidth; + const getY = (value: number) => padding.top + chartHeight - ((value - yMin) / (yMax - yMin)) * chartHeight; + + const generateSmoothPath = (points: { x: number; y: number }[]) => { + if (points.length < 2) return ''; + const path: string[] = []; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(i - 1, 0)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(i + 2, points.length - 1)]; + const tension = 0.3; + const cp1x = p1.x + (p2.x - p0.x) * tension; + const cp1y = p1.y + (p2.y - p0.y) * tension; + const cp2x = p2.x - (p3.x - p1.x) * tension; + const cp2y = p2.y - (p3.y - p1.y) * tension; + if (i === 0) path.push(`M ${p1.x} ${p1.y}`); + path.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`); + } + return path.join(' '); + }; + + const thisYearPoints = data.map((d, i) => ({ x: getX(i), y: getY(d.thisYear) })); + const lastYearPoints = data.map((d, i) => ({ x: getX(i), y: getY(d.lastYear) })); + const thisYearPath = generateSmoothPath(thisYearPoints); + const lastYearPath = generateSmoothPath(lastYearPoints); + const thisYearAreaPath = `${thisYearPath} L ${getX(data.length - 1)} ${padding.top + chartHeight} L ${padding.left} ${padding.top + chartHeight} Z`; + + const handleMouseEnter = (index: number, event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const svgRect = (event.currentTarget.closest('svg') as SVGSVGElement)?.getBoundingClientRect(); + if (svgRect) { + setTooltip({ + x: rect.left - svgRect.left + rect.width / 2, + y: rect.top - svgRect.top, + month: data[index].month, + thisYear: data[index].thisYear, + lastYear: data[index].lastYear, + }); + } + }; + + const handleMouseLeave = () => setTooltip(null); + return ( -
-
-

대시보드

-

실시간 마케팅 퍼포먼스를 확인하세요.

-
+
+ + + + + + + - {/* Top Stats Grid */} -
- - - -
+ {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => ( + + ))} - {/* Engagement Chart Section */} -
-
-

Engagement Overview

-
- - Weekly Active + {/* Last year line (purple, dashed) */} + + + {/* This year gradient fill */} + + + {/* This year line (mint, solid) */} + + + {/* Interactive data points */} + {thisYearPoints.map((point, i) => ( + + {/* Invisible larger hit area */} + handleMouseEnter(i, e)} + onMouseLeave={handleMouseLeave} + /> + {/* Visible point */} + handleMouseEnter(i, e)} + onMouseLeave={handleMouseLeave} + /> + + ))} + + {/* Animated pulsing dot on the last point */} + + + + + + + + + + {/* Tooltip */} + {tooltip && ( +
+
{tooltip.month}
+
+ + 올해 + {formatNumber(tooltip.thisYear)} +
+
+ + 작년 + {formatNumber(tooltip.lastYear)} +
+
+ {tooltip.thisYear > tooltip.lastYear ? '↑' : '↓'} {Math.abs(((tooltip.thisYear - tooltip.lastYear) / tooltip.lastYear) * 100).toFixed(1)}%
+ )} +
+ ); +}; -
- {/* Chart SVG */} - - - - - - - +// ===================================================== +// Icon Components +// ===================================================== - {/* Grid Lines */} - {[0, 100, 200, 300, 400].map(y => ( - - ))} +const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); - {/* Smooth Line Path */} - +const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); - {/* Gradient Fill */} - +// ===================================================== +// Content Components +// ===================================================== - {/* Pulsing Dot */} - - - - - - - +const TopContentItem: React.FC = ({ title, thumbnail, platform, views, engagement, date }) => ( +
+
+ {title} +
+ {platform === 'youtube' ? : } +
+
+
+

{title}

+
+ + + + + {views} + + + + + + {engagement} + +
+ {date} +
+
+); - {/* Floating Data Badge */} -
-
1,234
-
+const AudienceBarChart: React.FC<{ data: { label: string; percentage: number }[]; delay?: number }> = ({ data, delay = 0 }) => { + const [isAnimated, setIsAnimated] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsAnimated(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + return ( +
+ {data.map((item, index) => ( +
+ {item.label} +
+
+ {item.percentage}%
+ ))} +
+ ); +}; - {/* X Axis Labels */} -
- Mon - Tue - Wed - Thu - Fri - Sat - Sun +const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = ({ male, female, delay = 0 }) => { + const [isAnimated, setIsAnimated] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsAnimated(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + return ( +
+
+
+ {male}%
+
+ {female}% +
+
+
+ 남성 + 여성
); }; +// ===================================================== +// Main Component +// ===================================================== + +const DashboardContent: React.FC = () => { + const [selectedPlatform, setSelectedPlatform] = useState<'youtube' | 'instagram'>('youtube'); + + const currentPlatformData = PLATFORM_DATA.find(p => p.platform === selectedPlatform); + const lastUpdated = new Date().toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+ {/* Header */} + +
+
+

대시보드

+

실시간 마케팅 퍼포먼스를 확인하세요.

+
+ 마지막 업데이트: {lastUpdated} +
+
+ + {/* Content Performance Section */} + +

콘텐츠 성과

+
+ {CONTENT_METRICS.map((metric, index) => ( + + + + ))} +
+
+ + {/* Two Column Layout */} +
+ {/* Year over Year Growth Section */} + +
+
+

전년 대비 성장

+
+
+ + 올해 +
+
+ + 작년 +
+
+
+
+ +
+
+ {MONTHLY_DATA.map(d => ( + {d.month} + ))} +
+
+
+ + {/* Top Content Section */} + +
+

인기 콘텐츠

+
+ {TOP_CONTENT.map((content, index) => ( + + + + ))} +
+
+
+
+ + {/* Audience Insights Section */} + +

오디언스 인사이트

+
+ +
+

연령대 분포

+ +
+
+ +
+

성별 분포

+ +
+
+ +
+

상위 지역

+ ({ label: r.region, percentage: r.percentage }))} delay={1400} /> +
+
+
+
+ + {/* Platform Metrics Section */} + +
+
+

플랫폼별 지표

+
+ + +
+
+
+ {currentPlatformData?.metrics.map((metric, index) => ( + + + + ))} +
+
+
+
+ ); +}; + export default DashboardContent; diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index d7be301..7e80197 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -22,6 +22,7 @@ const ANALYSIS_DATA_KEY = 'castad_analysis_data'; // 다른 컴포넌트에서 사용하는 storage key들 (초기화용) const SONG_GENERATION_KEY = 'castad_song_generation'; const VIDEO_GENERATION_KEY = 'castad_video_generation'; +const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 // 모든 프로젝트 관련 localStorage 초기화 const clearAllProjectStorage = () => { @@ -30,6 +31,7 @@ const clearAllProjectStorage = () => { localStorage.removeItem(IMAGE_TASK_ID_KEY); localStorage.removeItem(SONG_GENERATION_KEY); localStorage.removeItem(VIDEO_GENERATION_KEY); + localStorage.removeItem(VIDEO_COMPLETE_KEY); }; interface BusinessInfo { @@ -291,6 +293,7 @@ const GenerationFlow: React.FC = ({ onNext={(taskId: string) => { // Clear video generation state to start fresh localStorage.removeItem(VIDEO_GENERATION_KEY); + localStorage.removeItem(VIDEO_COMPLETE_KEY); setVideoGenerationStatus('idle'); setVideoGenerationProgress(0); @@ -321,7 +324,15 @@ const GenerationFlow: React.FC = ({ case 3: return ( goToWizardStep(2)} + onBack={() => { + // 뒤로가기 시 비디오 생성 상태 초기화 + // 새 노래 생성 후 다시 영상 생성할 수 있도록 + localStorage.removeItem(VIDEO_GENERATION_KEY); + localStorage.removeItem(VIDEO_COMPLETE_KEY); + setVideoGenerationStatus('idle'); + setVideoGenerationProgress(0); + goToWizardStep(2); + }} songTaskId={songTaskId} onVideoStatusChange={setVideoGenerationStatus} onVideoProgressChange={setVideoGenerationProgress} diff --git a/src/pages/Social/SocialConnectError.tsx b/src/pages/Social/SocialConnectError.tsx new file mode 100644 index 0000000..e174aad --- /dev/null +++ b/src/pages/Social/SocialConnectError.tsx @@ -0,0 +1,48 @@ + +import React from 'react'; + +const SocialConnectError: React.FC = () => { + // URL 파라미터 파싱 + const searchParams = new URLSearchParams(window.location.search); + const platform = searchParams.get('platform') || 'unknown'; + const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.'; + + const platformName = platform === 'youtube' ? 'YouTube' : + platform === 'instagram' ? 'Instagram' : + platform === 'facebook' ? 'Facebook' : platform; + + const navigateToHome = () => { + window.location.href = '/'; + }; + + return ( +
+
+
+ + + + + +
+

{platformName} 연결 실패

+

+ {errorMessage} +

+

+ 다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요. +

+
+ +
+
+
+ ); +}; + +export default SocialConnectError; diff --git a/src/pages/Social/SocialConnectSuccess.tsx b/src/pages/Social/SocialConnectSuccess.tsx new file mode 100644 index 0000000..737938f --- /dev/null +++ b/src/pages/Social/SocialConnectSuccess.tsx @@ -0,0 +1,121 @@ + +import React, { useEffect, useState } from 'react'; + +// 연결된 소셜 계정 정보를 저장하는 localStorage 키 +const SOCIAL_CONNECTED_KEY = 'castad_social_connected'; + +interface ConnectedSocialAccount { + platform: string; + account_id: string; + channel_name: string; + profile_image: string | null; + connected_at: string; +} + +const SocialConnectSuccess: React.FC = () => { + const [countdown, setCountdown] = useState(3); + + // URL 파라미터 파싱 + const searchParams = new URLSearchParams(window.location.search); + const platform = searchParams.get('platform') || 'unknown'; + const accountId = searchParams.get('account_id') || ''; + const channelName = searchParams.get('channel_name') || ''; + const profileImage = searchParams.get('profile_image') || null; + + const platformName = platform === 'youtube' ? 'YouTube' : + platform === 'instagram' ? 'Instagram' : + platform === 'facebook' ? 'Facebook' : platform; + + // 연결된 계정 정보를 localStorage에 저장 + useEffect(() => { + if (platform && accountId) { + const connectedAccount: ConnectedSocialAccount = { + platform, + account_id: accountId, + channel_name: channelName, + profile_image: profileImage, + connected_at: new Date().toISOString(), + }; + localStorage.setItem(SOCIAL_CONNECTED_KEY, JSON.stringify(connectedAccount)); + console.log('[Social Connect] Account saved:', connectedAccount); + } + }, [platform, accountId, channelName, profileImage]); + + const navigateToHome = () => { + // URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동 + window.location.href = '/'; + }; + + useEffect(() => { + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(timer); + navigateToHome(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, []); + + return ( +
+
+ {/* 프로필 이미지가 있으면 표시 */} + {profileImage ? ( + {channelName + ) : ( +
+ + + + +
+ )} + +

+ {channelName || platformName} 연결 완료 +

+ +

+ {channelName ? ( + <>{platformName} 계정이 성공적으로 연결되었습니다. + ) : ( + <>계정이 성공적으로 연결되었습니다. + )} +

+ + {channelName && ( +
+ + + + + 인증됨 + +
+ )} + +

+ {countdown}초 후 자동으로 이동합니다... +

+ + +
+
+ ); +}; + +export default SocialConnectSuccess; diff --git a/src/types/api.ts b/src/types/api.ts index 51d9e07..98877f0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -263,3 +263,43 @@ export interface VideosListResponse { has_prev: boolean; } +// ============================================ +// Social OAuth Types +// ============================================ + +// YouTube OAuth 연결 응답 +export interface YouTubeConnectResponse { + auth_url: string; +} + +// 연결된 소셜 계정 정보 (서버 응답 형식) +export interface SocialAccount { + id: number; + platform: 'youtube' | 'instagram' | 'facebook'; + platform_user_id: string; + platform_username: string; + display_name: string; + is_active: boolean; + connected_at: string; + // OAuth 콜백에서 전달받는 추가 정보 (optional) + profile_image?: string | null; +} + +// 소셜 계정 목록 응답 +export interface SocialAccountsResponse { + accounts: SocialAccount[]; + total: number; +} + +// 소셜 계정 단일 조회 응답 +export interface SocialAccountResponse { + account: SocialAccount | null; + connected: boolean; +} + +// 소셜 계정 연결 해제 응답 +export interface SocialDisconnectResponse { + success: boolean; + message: string; +} + diff --git a/src/utils/api.ts b/src/utils/api.ts index 1a54f5a..1ae8245 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -18,6 +18,10 @@ import { KakaoCallbackResponse, TokenRefreshResponse, UserMeResponse, + YouTubeConnectResponse, + SocialAccountsResponse, + SocialAccountResponse, + SocialDisconnectResponse, } from '../types/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; @@ -665,3 +669,59 @@ export async function autocomplete(request: AutocompleteRequest): Promise { + const response = await authenticatedFetch(`${API_URL}/social/oauth/youtube/connect`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 연결된 소셜 계정 목록 조회 +export async function getSocialAccounts(): Promise { + const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 특정 플랫폼 계정 조회 +export async function getSocialAccountByPlatform(platform: 'youtube' | 'instagram' | 'facebook'): Promise { + const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts/${platform}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 소셜 계정 연결 해제 +export async function disconnectSocialAccount(platform: 'youtube' | 'instagram' | 'facebook'): Promise { + const response = await authenticatedFetch(`${API_URL}/social/oauth/${platform}/disconnect`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +}