youtube 계정연결 기능 작업
parent
cac825de19
commit
5c912e4bf8
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/App.tsx
13
src/App.tsx
|
|
@ -8,6 +8,8 @@ import LoadingSection from './pages/Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||||
import LoginSection from './pages/Login/LoginSection';
|
import LoginSection from './pages/Login/LoginSection';
|
||||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
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 { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
|
||||||
import { CrawlingResponse } from './types/api';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
|
|
@ -328,6 +330,17 @@ const App: React.FC = () => {
|
||||||
setViewMode('landing');
|
setViewMode('landing');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Social OAuth 콜백 페이지 처리
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
|
if (pathname === '/social/connect/success') {
|
||||||
|
return <SocialConnectSuccess />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/social/connect/error') {
|
||||||
|
return <SocialConnectError />;
|
||||||
|
}
|
||||||
|
|
||||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||||
if (isProcessingCallback) {
|
if (isProcessingCallback) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: '대시보드', label: '대시보드', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
{ id: '대시보드', label: '대시보드', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||||
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||||
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||||
{ id: '에셋 관리', label: '에셋 관리', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
{ id: '에셋 관리', label: '에셋 관리', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 {
|
interface CompletionContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -12,6 +13,7 @@ interface CompletionContentProps {
|
||||||
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
||||||
|
|
||||||
const VIDEO_STORAGE_KEY = 'castad_video_generation';
|
const VIDEO_STORAGE_KEY = 'castad_video_generation';
|
||||||
|
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 (만료 없음)
|
||||||
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
|
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
|
||||||
|
|
||||||
interface SavedVideoState {
|
interface SavedVideoState {
|
||||||
|
|
@ -39,6 +41,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hasStartedGeneration = useRef(false);
|
const hasStartedGeneration = useRef(false);
|
||||||
|
|
||||||
|
// YouTube 연결 상태
|
||||||
|
const [youtubeAccount, setYoutubeAccount] = useState<SocialAccount | null>(null);
|
||||||
|
const [isYoutubeConnecting, setIsYoutubeConnecting] = useState(false);
|
||||||
|
const [youtubeError, setYoutubeError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Notify parent of video status changes
|
// Notify parent of video status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onVideoStatusChange) {
|
if (onVideoStatusChange) {
|
||||||
|
|
@ -63,10 +70,33 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data));
|
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 = () => {
|
const clearStorage = () => {
|
||||||
localStorage.removeItem(VIDEO_STORAGE_KEY);
|
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 => {
|
const loadFromStorage = (): SavedVideoState | null => {
|
||||||
|
|
@ -200,6 +230,19 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
|
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
|
||||||
useEffect(() => {
|
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();
|
const savedState = loadFromStorage();
|
||||||
|
|
||||||
// 저장된 상태가 있고, 같은 songTaskId인 경우
|
// 저장된 상태가 있고, 같은 songTaskId인 경우
|
||||||
|
|
@ -216,12 +259,118 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
hasStartedGeneration.current = true;
|
hasStartedGeneration.current = true;
|
||||||
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
|
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
|
||||||
}
|
}
|
||||||
} else if (songTaskId && !hasStartedGeneration.current) {
|
} else if (!hasStartedGeneration.current) {
|
||||||
// 새로운 영상 생성 시작
|
// 새로운 영상 생성 시작
|
||||||
startVideoGeneration();
|
startVideoGeneration();
|
||||||
}
|
}
|
||||||
}, [songTaskId]);
|
}, [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) => {
|
const toggleSocial = (id: string) => {
|
||||||
setSelectedSocials(prev =>
|
setSelectedSocials(prev =>
|
||||||
prev.includes(id)
|
prev.includes(id)
|
||||||
|
|
@ -273,9 +422,16 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const socials = [
|
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: 'Youtube',
|
||||||
{ id: 'Facebook', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-facebook.png' },
|
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';
|
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
||||||
|
|
@ -388,20 +544,72 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<div className="social-list-new">
|
<div className="social-list-new">
|
||||||
{socials.map(social => {
|
{socials.map(social => {
|
||||||
const isSelected = selectedSocials.includes(social.id);
|
const isSelected = selectedSocials.includes(social.id);
|
||||||
|
const isYoutube = social.id === 'Youtube';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={social.id}
|
key={social.id}
|
||||||
onClick={() => videoStatus === 'complete' && toggleSocial(social.id)}
|
onClick={() => {
|
||||||
className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''}`}
|
if (videoStatus !== 'complete') return;
|
||||||
|
if (isYoutube) {
|
||||||
|
handleYoutubeConnect();
|
||||||
|
} else {
|
||||||
|
// Instagram, Facebook은 아직 미구현
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''} ${social.connected ? 'connected' : ''} ${isSelected ? 'selected' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="completion-social-info">
|
<div className="completion-social-info">
|
||||||
|
{/* 연결된 경우 채널 썸네일 표시, 아니면 플랫폼 로고 */}
|
||||||
|
{social.connected && social.thumbnailUrl ? (
|
||||||
|
<img src={social.thumbnailUrl} alt={social.channelName || social.id} className="completion-social-thumbnail" />
|
||||||
|
) : (
|
||||||
<img src={social.logo} alt={social.id} className="completion-social-logo" />
|
<img src={social.logo} alt={social.id} className="completion-social-logo" />
|
||||||
|
)}
|
||||||
|
<div className="completion-social-text">
|
||||||
|
{social.connected && social.channelName ? (
|
||||||
|
<>
|
||||||
|
<span className="completion-social-channel-name">{social.channelName}</span>
|
||||||
|
<span className="completion-social-platform">{social.id}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<span className="completion-social-name">{social.id}</span>
|
<span className="completion-social-name">{social.id}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="completion-social-email">{social.email}</span>
|
</div>
|
||||||
|
{social.isConnecting ? (
|
||||||
|
<span className="completion-social-status connecting">연결 중...</span>
|
||||||
|
) : social.connected ? (
|
||||||
|
<div className="completion-social-actions">
|
||||||
|
<span className="completion-social-status connected">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
인증됨
|
||||||
|
</span>
|
||||||
|
{isYoutube && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleYoutubeDisconnect();
|
||||||
|
}}
|
||||||
|
className="completion-social-disconnect"
|
||||||
|
>
|
||||||
|
해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="completion-social-status not-connected">
|
||||||
|
{isYoutube ? '계정 연결' : '준비 중'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{youtubeError && (
|
||||||
|
<p className="text-red-400 text-sm mt-2">{youtubeError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,688 @@
|
||||||
|
|
||||||
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<AnimatedItemProps> = ({ 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 (
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-500 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AnimatedSectionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedSection: React.FC<AnimatedSectionProps> = ({ children, delay = 0, className = '' }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-600 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// UI Components
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
|
||||||
|
<svg
|
||||||
|
className="stat-trend-icon"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{direction === 'up' ? (
|
||||||
|
<path d="M6 9V3M3 5l3-3 3 3" />
|
||||||
|
) : (
|
||||||
|
<path d="M6 3v6M3 7l3 3 3-3" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StatCard: React.FC<ContentMetric> = ({ labelEn, value, trend, trendDirection }) => (
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<span className="stat-label">{label}</span>
|
<span className="stat-label">{labelEn}</span>
|
||||||
<h3 className="stat-value">{value}</h3>
|
<h3 className="stat-value">{value}</h3>
|
||||||
<span className="stat-trend">{trend}</span>
|
<div className="stat-trend-wrapper">
|
||||||
|
<span className={`stat-trend ${trendDirection}`}>
|
||||||
|
<TrendIcon direction={trendDirection} />
|
||||||
|
{trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DashboardContent: React.FC = () => {
|
const MetricCard: React.FC<PlatformMetric> = ({ label, value, unit, trend, trendDirection }) => (
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="metric-card-header">
|
||||||
|
<span className="metric-card-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-value">
|
||||||
|
{value}
|
||||||
|
{unit && <span className="metric-card-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={`metric-card-trend ${trendDirection}`}>
|
||||||
|
<TrendIcon direction={trendDirection} />
|
||||||
|
{trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 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<TooltipData | null>(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 (
|
return (
|
||||||
<div className="dashboard-container">
|
<div className="yoy-chart-wrapper">
|
||||||
<div className="dashboard-header">
|
<svg className="yoy-chart-svg" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||||||
<h1 className="dashboard-title">대시보드</h1>
|
|
||||||
<p className="dashboard-description">실시간 마케팅 퍼포먼스를 확인하세요.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Stats Grid */}
|
|
||||||
<div className="stats-grid">
|
|
||||||
<StatCard label="TOTAL REACH" value="124.5k" trend="지난 주 12%" />
|
|
||||||
<StatCard label="CONVERSIONS" value="3,892" trend="지난 주 8%" />
|
|
||||||
<StatCard label="SEO SCORE" value="98/100" trend="지난 주 12%" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Engagement Chart Section */}
|
|
||||||
<div className="chart-card">
|
|
||||||
<div className="chart-header">
|
|
||||||
<h2 className="chart-title">Engagement Overview</h2>
|
|
||||||
<div className="chart-legend">
|
|
||||||
<span className="chart-legend-dot"></span>
|
|
||||||
<span className="chart-legend-text">Weekly Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chart-container">
|
|
||||||
{/* Chart SVG */}
|
|
||||||
<svg
|
|
||||||
className="w-full h-full overflow-visible"
|
|
||||||
viewBox="0 0 1000 400"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="thisYearGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.2" />
|
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.25" />
|
||||||
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
|
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Grid Lines */}
|
{/* Grid lines */}
|
||||||
{[0, 100, 200, 300, 400].map(y => (
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
|
||||||
<line key={y} x1="0" y1={y} x2="1000" y2={y} stroke="white" strokeOpacity="0.03" strokeWidth="1" />
|
<line key={i} x1={padding.left} y1={padding.top + chartHeight * ratio} x2={width - padding.right} y2={padding.top + chartHeight * ratio} stroke="white" strokeOpacity="0.05" />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Smooth Line Path */}
|
{/* Last year line (purple, dashed) */}
|
||||||
<path
|
<path
|
||||||
d="M0,320 C100,340 200,220 300,260 C400,300 450,140 550,180 C650,220 750,340 850,280 C950,220 1000,80 1000,80"
|
d={lastYearPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="#a682ff"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="6,4"
|
||||||
|
opacity="0.7"
|
||||||
|
className={`chart-line-animated ${isAnimated ? 'visible' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* This year gradient fill */}
|
||||||
|
<path d={thisYearAreaPath} fill="url(#thisYearGradient)" className={`chart-area-animated ${isAnimated ? 'visible' : ''}`} />
|
||||||
|
|
||||||
|
{/* This year line (mint, solid) */}
|
||||||
|
<path
|
||||||
|
d={thisYearPath}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#a6ffea"
|
stroke="#a6ffea"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
className={`chart-line-animated ${isAnimated ? 'visible' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gradient Fill */}
|
{/* Interactive data points */}
|
||||||
<path
|
{thisYearPoints.map((point, i) => (
|
||||||
d="M0,320 C100,340 200,220 300,260 C400,300 450,140 550,180 C650,220 750,340 850,280 C950,220 1000,80 1000,80 V400 H0 Z"
|
<g key={i}>
|
||||||
fill="url(#chartGradient)"
|
{/* Invisible larger hit area */}
|
||||||
|
<circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r="20"
|
||||||
|
fill="transparent"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onMouseEnter={(e) => handleMouseEnter(i, e)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
/>
|
/>
|
||||||
|
{/* Visible point */}
|
||||||
|
<circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={tooltip?.month === data[i].month ? 6 : 4}
|
||||||
|
fill="#a6ffea"
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(0 0 6px rgba(166,255,234,0.6))',
|
||||||
|
transition: 'r 0.2s ease',
|
||||||
|
}}
|
||||||
|
className={`chart-point-animated ${isAnimated ? 'visible' : ''}`}
|
||||||
|
onMouseEnter={(e) => handleMouseEnter(i, e)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Pulsing Dot */}
|
{/* Animated pulsing dot on the last point */}
|
||||||
<g transform="translate(550, 180)">
|
<g transform={`translate(${thisYearPoints[thisYearPoints.length - 1].x}, ${thisYearPoints[thisYearPoints.length - 1].y})`}>
|
||||||
<circle r="5" fill="#a6ffea">
|
<circle r="4" fill="#a6ffea">
|
||||||
<animate attributeName="r" from="5" to="15" dur="2s" repeatCount="indefinite" />
|
<animate attributeName="r" from="4" to="12" dur="2s" repeatCount="indefinite" />
|
||||||
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
|
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
|
||||||
</circle>
|
</circle>
|
||||||
<circle r="6" fill="#a6ffea" style={{ filter: 'drop-shadow(0 0 12px rgba(166,255,234,0.8))' }} />
|
<circle r="5" fill="#a6ffea" style={{ filter: 'drop-shadow(0 0 10px rgba(166,255,234,0.8))' }} />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Floating Data Badge */}
|
{/* Tooltip */}
|
||||||
<div className="chart-badge">
|
{tooltip && (
|
||||||
<div className="chart-badge-value">1,234</div>
|
<div
|
||||||
<div className="chart-badge-line"></div>
|
className="chart-tooltip"
|
||||||
|
style={{
|
||||||
|
left: `${(tooltip.x / width) * 100}%`,
|
||||||
|
top: `${(tooltip.y / height) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chart-tooltip-title">{tooltip.month}</div>
|
||||||
|
<div className="chart-tooltip-row">
|
||||||
|
<span className="chart-tooltip-dot mint"></span>
|
||||||
|
<span className="chart-tooltip-label">올해</span>
|
||||||
|
<span className="chart-tooltip-value">{formatNumber(tooltip.thisYear)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chart-tooltip-row">
|
||||||
|
<span className="chart-tooltip-dot purple"></span>
|
||||||
|
<span className="chart-tooltip-label">작년</span>
|
||||||
|
<span className="chart-tooltip-value">{formatNumber(tooltip.lastYear)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chart-tooltip-change">
|
||||||
|
{tooltip.thisYear > tooltip.lastYear ? '↑' : '↓'} {Math.abs(((tooltip.thisYear - tooltip.lastYear) / tooltip.lastYear) * 100).toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* X Axis Labels */}
|
// =====================================================
|
||||||
<div className="chart-xaxis">
|
// Icon Components
|
||||||
<span>Mon</span>
|
// =====================================================
|
||||||
<span>Tue</span>
|
|
||||||
<span>Wed</span>
|
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
<span>Thu</span>
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
||||||
<span>Fri</span>
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
<span>Sat</span>
|
</svg>
|
||||||
<span>Sun</span>
|
);
|
||||||
|
|
||||||
|
const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Content Components
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
const TopContentItem: React.FC<TopContent> = ({ title, thumbnail, platform, views, engagement, date }) => (
|
||||||
|
<div className="top-content-item">
|
||||||
|
<div className="top-content-thumbnail">
|
||||||
|
<img src={thumbnail} alt={title} />
|
||||||
|
<div className="top-content-platform-badge">
|
||||||
|
{platform === 'youtube' ? <YouTubeIcon className="top-content-platform-icon" /> : <InstagramIcon className="top-content-platform-icon" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="top-content-info">
|
||||||
|
<h4 className="top-content-title">{title}</h4>
|
||||||
|
<div className="top-content-stats">
|
||||||
|
<span className="top-content-stat">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{views}
|
||||||
|
</span>
|
||||||
|
<span className="top-content-stat">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
{engagement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="top-content-date">{date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="audience-bar-chart">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<div key={item.label} className="audience-bar-item">
|
||||||
|
<span className="audience-bar-label">{item.label}</span>
|
||||||
|
<div className="audience-bar-track">
|
||||||
|
<div
|
||||||
|
className="audience-bar-fill"
|
||||||
|
style={{
|
||||||
|
width: isAnimated ? `${item.percentage}%` : '0%',
|
||||||
|
transitionDelay: `${index * 100}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="audience-bar-value">{item.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="gender-chart">
|
||||||
|
<div className="gender-chart-bars">
|
||||||
|
<div className="gender-bar male" style={{ width: isAnimated ? `${male}%` : '0%' }}>
|
||||||
|
<span>{male}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="gender-bar female" style={{ width: isAnimated ? `${female}%` : '0%' }}>
|
||||||
|
<span>{female}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="gender-chart-labels">
|
||||||
|
<span className="gender-label male">남성</span>
|
||||||
|
<span className="gender-label female">여성</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 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 (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
{/* Header */}
|
||||||
|
<AnimatedSection delay={0}>
|
||||||
|
<div className="dashboard-header-row">
|
||||||
|
<div>
|
||||||
|
<h1 className="dashboard-title">대시보드</h1>
|
||||||
|
<p className="dashboard-description">실시간 마케팅 퍼포먼스를 확인하세요.</p>
|
||||||
|
</div>
|
||||||
|
<span className="dashboard-last-updated">마지막 업데이트: {lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Content Performance Section */}
|
||||||
|
<AnimatedSection delay={100} className="dashboard-section">
|
||||||
|
<h2 className="dashboard-section-title">콘텐츠 성과</h2>
|
||||||
|
<div className="stats-grid-8">
|
||||||
|
{CONTENT_METRICS.map((metric, index) => (
|
||||||
|
<AnimatedItem key={metric.id} index={index} baseDelay={200}>
|
||||||
|
<StatCard {...metric} />
|
||||||
|
</AnimatedItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Two Column Layout */}
|
||||||
|
<div className="dashboard-two-column">
|
||||||
|
{/* Year over Year Growth Section */}
|
||||||
|
<AnimatedSection delay={600}>
|
||||||
|
<div className="yoy-chart-card">
|
||||||
|
<div className="yoy-chart-header">
|
||||||
|
<h2 className="dashboard-section-title">전년 대비 성장</h2>
|
||||||
|
<div className="chart-legend-dual">
|
||||||
|
<div className="chart-legend-item">
|
||||||
|
<span className="chart-legend-line solid"></span>
|
||||||
|
<span className="chart-legend-text">올해</span>
|
||||||
|
</div>
|
||||||
|
<div className="chart-legend-item">
|
||||||
|
<span className="chart-legend-line dashed"></span>
|
||||||
|
<span className="chart-legend-text">작년</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="yoy-chart-container">
|
||||||
|
<YearOverYearChart data={MONTHLY_DATA} />
|
||||||
|
</div>
|
||||||
|
<div className="yoy-chart-xaxis">
|
||||||
|
{MONTHLY_DATA.map(d => (
|
||||||
|
<span key={d.month}>{d.month}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Top Content Section */}
|
||||||
|
<AnimatedSection delay={700}>
|
||||||
|
<div className="top-content-card">
|
||||||
|
<h2 className="dashboard-section-title">인기 콘텐츠</h2>
|
||||||
|
<div className="top-content-list">
|
||||||
|
{TOP_CONTENT.map((content, index) => (
|
||||||
|
<AnimatedItem key={content.id} index={index} baseDelay={800}>
|
||||||
|
<TopContentItem {...content} />
|
||||||
|
</AnimatedItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audience Insights Section */}
|
||||||
|
<AnimatedSection delay={1000} className="audience-section">
|
||||||
|
<h2 className="dashboard-section-title">오디언스 인사이트</h2>
|
||||||
|
<div className="audience-cards">
|
||||||
|
<AnimatedItem index={0} baseDelay={1100}>
|
||||||
|
<div className="audience-card">
|
||||||
|
<h3 className="audience-card-title">연령대 분포</h3>
|
||||||
|
<AudienceBarChart data={AUDIENCE_DATA.ageGroups} delay={1200} />
|
||||||
|
</div>
|
||||||
|
</AnimatedItem>
|
||||||
|
<AnimatedItem index={1} baseDelay={1100}>
|
||||||
|
<div className="audience-card">
|
||||||
|
<h3 className="audience-card-title">성별 분포</h3>
|
||||||
|
<GenderChart male={AUDIENCE_DATA.gender.male} female={AUDIENCE_DATA.gender.female} delay={1300} />
|
||||||
|
</div>
|
||||||
|
</AnimatedItem>
|
||||||
|
<AnimatedItem index={2} baseDelay={1100}>
|
||||||
|
<div className="audience-card">
|
||||||
|
<h3 className="audience-card-title">상위 지역</h3>
|
||||||
|
<AudienceBarChart data={AUDIENCE_DATA.topRegions.map(r => ({ label: r.region, percentage: r.percentage }))} delay={1400} />
|
||||||
|
</div>
|
||||||
|
</AnimatedItem>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Platform Metrics Section */}
|
||||||
|
<AnimatedSection delay={1300}>
|
||||||
|
<div className="platform-section-card">
|
||||||
|
<div className="platform-section-header">
|
||||||
|
<h2 className="dashboard-section-title">플랫폼별 지표</h2>
|
||||||
|
<div className="platform-tabs">
|
||||||
|
<button
|
||||||
|
className={`platform-tab ${selectedPlatform === 'youtube' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedPlatform('youtube')}
|
||||||
|
>
|
||||||
|
<YouTubeIcon />
|
||||||
|
YouTube
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`platform-tab ${selectedPlatform === 'instagram' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedPlatform('instagram')}
|
||||||
|
>
|
||||||
|
<InstagramIcon />
|
||||||
|
Instagram
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="platform-metrics-grid-8">
|
||||||
|
{currentPlatformData?.metrics.map((metric, index) => (
|
||||||
|
<AnimatedItem key={metric.id} index={index} baseDelay={1400}>
|
||||||
|
<MetricCard {...metric} />
|
||||||
|
</AnimatedItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||||
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||||
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
const VIDEO_GENERATION_KEY = 'castad_video_generation';
|
||||||
|
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보
|
||||||
|
|
||||||
// 모든 프로젝트 관련 localStorage 초기화
|
// 모든 프로젝트 관련 localStorage 초기화
|
||||||
const clearAllProjectStorage = () => {
|
const clearAllProjectStorage = () => {
|
||||||
|
|
@ -30,6 +31,7 @@ const clearAllProjectStorage = () => {
|
||||||
localStorage.removeItem(IMAGE_TASK_ID_KEY);
|
localStorage.removeItem(IMAGE_TASK_ID_KEY);
|
||||||
localStorage.removeItem(SONG_GENERATION_KEY);
|
localStorage.removeItem(SONG_GENERATION_KEY);
|
||||||
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BusinessInfo {
|
interface BusinessInfo {
|
||||||
|
|
@ -291,6 +293,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
onNext={(taskId: string) => {
|
onNext={(taskId: string) => {
|
||||||
// Clear video generation state to start fresh
|
// Clear video generation state to start fresh
|
||||||
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
setVideoGenerationStatus('idle');
|
setVideoGenerationStatus('idle');
|
||||||
setVideoGenerationProgress(0);
|
setVideoGenerationProgress(0);
|
||||||
|
|
||||||
|
|
@ -321,7 +324,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<CompletionContent
|
<CompletionContent
|
||||||
onBack={() => goToWizardStep(2)}
|
onBack={() => {
|
||||||
|
// 뒤로가기 시 비디오 생성 상태 초기화
|
||||||
|
// 새 노래 생성 후 다시 영상 생성할 수 있도록
|
||||||
|
localStorage.removeItem(VIDEO_GENERATION_KEY);
|
||||||
|
localStorage.removeItem(VIDEO_COMPLETE_KEY);
|
||||||
|
setVideoGenerationStatus('idle');
|
||||||
|
setVideoGenerationProgress(0);
|
||||||
|
goToWizardStep(2);
|
||||||
|
}}
|
||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="social-connect-page">
|
||||||
|
<div className="social-connect-card error">
|
||||||
|
<div className="social-connect-icon error">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="social-connect-title">{platformName} 연결 실패</h1>
|
||||||
|
<p className="social-connect-message">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
<p className="social-connect-detail">
|
||||||
|
다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.
|
||||||
|
</p>
|
||||||
|
<div className="social-connect-actions">
|
||||||
|
<button
|
||||||
|
onClick={navigateToHome}
|
||||||
|
className="social-connect-button"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SocialConnectError;
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="social-connect-page">
|
||||||
|
<div className="social-connect-card success">
|
||||||
|
{/* 프로필 이미지가 있으면 표시 */}
|
||||||
|
{profileImage ? (
|
||||||
|
<img
|
||||||
|
src={profileImage}
|
||||||
|
alt={channelName || platformName}
|
||||||
|
className="social-connect-profile-image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="social-connect-icon success">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="social-connect-title">
|
||||||
|
{channelName || platformName} 연결 완료
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="social-connect-message">
|
||||||
|
{channelName ? (
|
||||||
|
<>{platformName} 계정이 성공적으로 연결되었습니다.</>
|
||||||
|
) : (
|
||||||
|
<>계정이 성공적으로 연결되었습니다.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{channelName && (
|
||||||
|
<div className="social-connect-account-info">
|
||||||
|
<span className="social-connect-channel-badge">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
인증됨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="social-connect-countdown">
|
||||||
|
{countdown}초 후 자동으로 이동합니다...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={navigateToHome}
|
||||||
|
className="social-connect-button"
|
||||||
|
>
|
||||||
|
지금 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SocialConnectSuccess;
|
||||||
|
|
@ -263,3 +263,43 @@ export interface VideosListResponse {
|
||||||
has_prev: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import {
|
||||||
KakaoCallbackResponse,
|
KakaoCallbackResponse,
|
||||||
TokenRefreshResponse,
|
TokenRefreshResponse,
|
||||||
UserMeResponse,
|
UserMeResponse,
|
||||||
|
YouTubeConnectResponse,
|
||||||
|
SocialAccountsResponse,
|
||||||
|
SocialAccountResponse,
|
||||||
|
SocialDisconnectResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
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<Crawli
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Social OAuth API (YouTube, Instagram, Facebook)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// YouTube 연결 URL 획득
|
||||||
|
export async function getYouTubeConnectUrl(): Promise<YouTubeConnectResponse> {
|
||||||
|
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<SocialAccountsResponse> {
|
||||||
|
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<SocialAccountResponse> {
|
||||||
|
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<SocialDisconnectResponse> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue