youtube 계정연결 기능 작업

main
hbyang 2026-02-02 12:39:51 +09:00
parent cac825de19
commit 5c912e4bf8
11 changed files with 2918 additions and 86 deletions

585
api/social_api_spec.json Normal file
View File

@ -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)"
}
}
}

1159
index.css

File diff suppressed because it is too large Load Diff

View File

@ -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 <SocialConnectSuccess />;
}
if (pathname === '/social/connect/error') {
return <SocialConnectError />;
}
// 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) {
return (

View File

@ -55,7 +55,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
};
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: '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> },

View File

@ -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<CompletionContentProps> = ({
const videoRef = useRef<HTMLVideoElement>(null);
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
useEffect(() => {
if (onVideoStatusChange) {
@ -63,10 +70,33 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
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<CompletionContentProps> = ({
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
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<CompletionContentProps> = ({
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<CompletionContentProps> = ({
};
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<CompletionContentProps> = ({
<div className="social-list-new">
{socials.map(social => {
const isSelected = selectedSocials.includes(social.id);
const isYoutube = social.id === 'Youtube';
return (
<div
key={social.id}
onClick={() => 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' : ''}`}
>
<div className="completion-social-info">
<img src={social.logo} alt={social.id} className="completion-social-logo" />
<span className="completion-social-name">{social.id}</span>
{/* 연결된 경우 채널 썸네일 표시, 아니면 플랫폼 로고 */}
{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" />
)}
<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>
)}
</div>
</div>
<span className="completion-social-email">{social.email}</span>
{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>
);
})}
{youtubeError && (
<p className="text-red-400 text-sm mt-2">{youtubeError}</p>
)}
</div>
</div>

View File

@ -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<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">
<span className="stat-label">{label}</span>
<span className="stat-label">{labelEn}</span>
<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>
);
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 (
<div className="dashboard-container">
<div className="dashboard-header">
<h1 className="dashboard-title"></h1>
<p className="dashboard-description"> .</p>
</div>
<div className="yoy-chart-wrapper">
<svg className="yoy-chart-svg" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
<defs>
<linearGradient id="thisYearGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.25" />
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
</linearGradient>
</defs>
{/* 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>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
<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" />
))}
{/* 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>
{/* Last year line (purple, dashed) */}
<path
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"
stroke="#a6ffea"
strokeWidth="3"
strokeLinecap="round"
className={`chart-line-animated ${isAnimated ? 'visible' : ''}`}
/>
{/* Interactive data points */}
{thisYearPoints.map((point, i) => (
<g key={i}>
{/* 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>
))}
{/* Animated pulsing dot on the last point */}
<g transform={`translate(${thisYearPoints[thisYearPoints.length - 1].x}, ${thisYearPoints[thisYearPoints.length - 1].y})`}>
<circle r="4" fill="#a6ffea">
<animate attributeName="r" from="4" to="12" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
</circle>
<circle r="5" fill="#a6ffea" style={{ filter: 'drop-shadow(0 0 10px rgba(166,255,234,0.8))' }} />
</g>
</svg>
{/* Tooltip */}
{tooltip && (
<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 className="chart-container">
{/* Chart SVG */}
<svg
className="w-full h-full overflow-visible"
viewBox="0 0 1000 400"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.2" />
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
</linearGradient>
</defs>
// =====================================================
// Icon Components
// =====================================================
{/* Grid Lines */}
{[0, 100, 200, 300, 400].map(y => (
<line key={y} x1="0" y1={y} x2="1000" y2={y} stroke="white" strokeOpacity="0.03" strokeWidth="1" />
))}
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
);
{/* Smooth Line 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"
fill="none"
stroke="#a6ffea"
strokeWidth="3"
strokeLinecap="round"
/>
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>
);
{/* Gradient Fill */}
<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 V400 H0 Z"
fill="url(#chartGradient)"
/>
// =====================================================
// Content Components
// =====================================================
{/* Pulsing Dot */}
<g transform="translate(550, 180)">
<circle r="5" fill="#a6ffea">
<animate attributeName="r" from="5" to="15" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
</circle>
<circle r="6" fill="#a6ffea" style={{ filter: 'drop-shadow(0 0 12px rgba(166,255,234,0.8))' }} />
</g>
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 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>
);
{/* Floating Data Badge */}
<div className="chart-badge">
<div className="chart-badge-value">1,234</div>
<div className="chart-badge-line"></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>
);
};
{/* X Axis Labels */}
<div className="chart-xaxis">
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
<span>Sun</span>
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>
);
};
export default DashboardContent;

View File

@ -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<GenerationFlowProps> = ({
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<GenerationFlowProps> = ({
case 3:
return (
<CompletionContent
onBack={() => 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}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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<Crawli
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();
}