유튜브 업로드 기능 완료 .

main
hbyang 2026-02-02 16:43:41 +09:00
parent e1b860a15f
commit fd9db31e52
12 changed files with 2257 additions and 30 deletions

View File

@ -8,7 +8,8 @@
"Bash(npm run build:*)", "Bash(npm run build:*)",
"Bash(python3:*)", "Bash(python3:*)",
"mcp__figma__get_figma_data", "mcp__figma__get_figma_data",
"mcp__figma__download_figma_images" "mcp__figma__download_figma_images",
"Bash(npx tsc:*)"
] ]
} }
} }

1181
index.css

File diff suppressed because it is too large Load Diff

View File

@ -58,10 +58,8 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
{ 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"><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> },
{ id: '내 펜션', label: '내 펜션', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> }, { id: '내 정보', label: '내 정보', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
{ id: '계정 설정', label: '계정 설정', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
{ id: '비즈니스 설정', label: '비즈니스 설정', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1-2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> },
]; ];
return ( return (

View File

@ -0,0 +1,487 @@
import React, { useState, useEffect, useRef } from 'react';
import { getSocialAccounts, uploadToSocial, waitForUploadComplete } from '../utils/api';
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
interface SocialPostingModalProps {
isOpen: boolean;
onClose: () => void;
video: VideoListItem | null;
}
type PrivacyType = 'public' | 'unlisted' | 'private';
type PublishTimeType = 'now' | 'schedule';
// 플랫폼별 아이콘 경로
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'youtube':
return '/assets/images/social-youtube.png';
case 'instagram':
return '/assets/images/social-instagram.png';
default:
return '/assets/images/social-youtube.png';
}
};
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
isOpen,
onClose,
video
}) => {
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [privacy, setPrivacy] = useState<PrivacyType>('public');
const [publishTime, setPublishTime] = useState<PublishTimeType>('now');
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
const [isPosting, setIsPosting] = useState(false);
const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false);
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
// Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>('pending');
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadYoutubeUrl, setUploadYoutubeUrl] = useState<string | undefined>();
const [uploadErrorMessage, setUploadErrorMessage] = useState<string | undefined>();
// 업로드 정보 (모달이 닫힌 후에도 유지)
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
const [uploadChannelName, setUploadChannelName] = useState<string>('');
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (channelDropdownRef.current && !channelDropdownRef.current.contains(event.target as Node)) {
setIsChannelDropdownOpen(false);
}
if (privacyDropdownRef.current && !privacyDropdownRef.current.contains(event.target as Node)) {
setIsPrivacyDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 소셜 계정 로드
useEffect(() => {
if (isOpen) {
loadSocialAccounts();
// 비디오 정보로 기본 제목 설정
if (video) {
const date = new Date(video.created_at);
const formattedDate = `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
setTitle(`${video.store_name} ${formattedDate}`);
}
}
}, [isOpen, video]);
const loadSocialAccounts = async () => {
setIsLoadingAccounts(true);
try {
const response = await getSocialAccounts();
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
setSocialAccounts(activeAccounts);
if (activeAccounts.length > 0) {
setSelectedChannel(activeAccounts[0].platform_user_id);
}
} catch (error) {
console.error('Failed to load social accounts:', error);
} finally {
setIsLoadingAccounts(false);
}
};
const handlePost = async () => {
if (!selectedChannel || !title.trim() || !video) {
alert('채널과 제목을 입력해주세요.');
return;
}
const selectedAcc = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
if (!selectedAcc) {
alert('채널을 선택해주세요.');
return;
}
// video.video_id 검증 - 반드시 존재해야 함
if (!video.video_id) {
console.error('Video object missing video_id:', video);
alert('영상 정보가 올바르지 않습니다. (video_id 누락)');
return;
}
setIsPosting(true);
// Reset upload progress state
setUploadStatus('pending');
setUploadProgress(0);
setUploadYoutubeUrl(undefined);
setUploadErrorMessage(undefined);
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
setUploadVideoTitle(title.trim());
setUploadChannelName(selectedAcc.display_name);
setShowUploadProgress(true);
try {
// Parse tags from comma-separated string
const tagsArray = tags
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
// Request payload 로그
const requestPayload = {
video_id: video.video_id,
social_account_id: selectedAcc.id,
title: title.trim(),
description: description.trim(),
tags: tagsArray,
privacy_status: privacy,
scheduled_at: publishTime === 'now' ? null : null,
};
console.log('[Upload] Request payload:', requestPayload);
// Call upload API
const uploadResponse = await uploadToSocial(requestPayload);
if (!uploadResponse.success) {
throw new Error(uploadResponse.message || '업로드 시작에 실패했습니다.');
}
// Poll for upload completion
const result = await waitForUploadComplete(
uploadResponse.upload_id,
(status, progress) => {
setUploadStatus(status as UploadStatus);
setUploadProgress(progress || 0);
}
);
// Upload completed successfully
setUploadStatus('completed');
setUploadProgress(100);
setUploadYoutubeUrl(result.platform_url);
// Close the posting modal (keep progress modal open)
onClose();
resetForm();
} catch (error) {
console.error('Upload failed:', error);
setUploadStatus('failed');
setUploadErrorMessage(error instanceof Error ? error.message : '업로드에 실패했습니다.');
} finally {
setIsPosting(false);
}
};
const resetForm = () => {
setTitle('');
setDescription('');
setTags('');
setPrivacy('public');
setPublishTime('now');
setSelectedChannel('');
setIsChannelDropdownOpen(false);
setIsPrivacyDropdownOpen(false);
};
const handleUploadProgressClose = () => {
setShowUploadProgress(false);
setUploadStatus('pending');
setUploadProgress(0);
setUploadYoutubeUrl(undefined);
setUploadErrorMessage(undefined);
setUploadVideoTitle('');
setUploadChannelName('');
};
const handleClose = () => {
onClose();
resetForm();
};
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
const privacyOptions = [
{ value: 'public', label: '공개' },
{ value: 'unlisted', label: '미등록 (링크로만 접근)' },
{ value: 'private', label: '비공개' }
];
const selectedPrivacyOption = privacyOptions.find(opt => opt.value === privacy);
// Always render UploadProgressModal, even when main modal is closed
const uploadProgressModalElement = (
<UploadProgressModal
isOpen={showUploadProgress}
onClose={handleUploadProgressClose}
status={uploadStatus}
progress={uploadProgress}
videoTitle={uploadVideoTitle || title || (video?.store_name || '')}
channelName={uploadChannelName || selectedAccount?.display_name || ''}
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
/>
);
if (!isOpen || !video) {
// Still render upload progress modal even when main modal is closed
return showUploadProgress ? uploadProgressModalElement : null;
}
return (
<>
<div className="social-posting-overlay" onClick={handleClose}>
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="social-posting-header">
<h2 className="social-posting-title"> </h2>
<button className="social-posting-close" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Content */}
<div className="social-posting-content">
{/* Left: Video Preview */}
<div className="social-posting-preview">
<div className="social-posting-video-container">
<video
src={video.result_movie_url}
className="social-posting-video"
controls
playsInline
/>
</div>
</div>
{/* Right: Form */}
<div className="social-posting-form">
{/* Video Info */}
<div className="social-posting-video-info">
<span className="social-posting-label-badge"> 1</span>
<span className="social-posting-add-btn">+</span>
</div>
<div className="social-posting-video-meta">
<p className="social-posting-video-title">
{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}
</p>
<p className="social-posting-video-specs">1080x1920 · 10</p>
</div>
{/* Channel Selector - Custom Dropdown */}
<div className="social-posting-field">
<label className="social-posting-label">
<span className="required">*</span>
</label>
{isLoadingAccounts ? (
<div className="social-posting-loading"> ...</div>
) : socialAccounts.length === 0 ? (
<div className="social-posting-no-accounts">
<p> .</p>
<p className="social-posting-no-accounts-hint"> .</p>
</div>
) : (
<div className="social-posting-channel-dropdown" ref={channelDropdownRef}>
<button
type="button"
className={`social-posting-channel-trigger ${isChannelDropdownOpen ? 'open' : ''}`}
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
>
<div className="social-posting-channel-selected">
{selectedAccount && (
<>
<img
src={getPlatformIcon(selectedAccount.platform)}
alt={selectedAccount.platform}
className="social-posting-channel-icon"
/>
<span>{selectedAccount.display_name}</span>
</>
)}
</div>
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
<path d="M1 1.5L6 6.5L11 1.5" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{isChannelDropdownOpen && (
<div className="social-posting-channel-menu">
{socialAccounts.map(account => (
<button
key={account.id}
type="button"
className={`social-posting-channel-option ${selectedChannel === account.platform_user_id ? 'selected' : ''}`}
onClick={() => {
setSelectedChannel(account.platform_user_id);
setIsChannelDropdownOpen(false);
}}
>
<img
src={getPlatformIcon(account.platform)}
alt={account.platform}
className="social-posting-channel-option-icon"
/>
<span>{account.display_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Title */}
<div className="social-posting-field">
<label className="social-posting-label">
<span className="required">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="게시물 제목을 입력하세요."
className="social-posting-input"
maxLength={100}
/>
<span className="social-posting-char-count">{title.length}/100</span>
</div>
{/* Description */}
<div className="social-posting-field">
<label className="social-posting-label"> </label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="게시물 내용을 입력하세요."
className="social-posting-textarea"
maxLength={5000}
rows={4}
/>
<span className="social-posting-char-count">{description.length}/5,000</span>
</div>
{/* Tags */}
<div className="social-posting-field">
<label className="social-posting-label"></label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="태그를 입력하세요. (쉼표로 구분)"
className="social-posting-input"
maxLength={500}
/>
<span className="social-posting-char-count">{tags.length}/500</span>
</div>
{/* Privacy - Custom Dropdown */}
<div className="social-posting-field">
<label className="social-posting-label">
<span className="required">*</span>
</label>
<div className="social-posting-channel-dropdown" ref={privacyDropdownRef}>
<button
type="button"
className={`social-posting-channel-trigger ${isPrivacyDropdownOpen ? 'open' : ''}`}
onClick={() => setIsPrivacyDropdownOpen(!isPrivacyDropdownOpen)}
>
<div className="social-posting-channel-selected">
<span>{selectedPrivacyOption?.label}</span>
</div>
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
<path d="M1 1.5L6 6.5L11 1.5" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{isPrivacyDropdownOpen && (
<div className="social-posting-channel-menu">
{privacyOptions.map(option => (
<button
key={option.value}
type="button"
className={`social-posting-channel-option ${privacy === option.value ? 'selected' : ''}`}
onClick={() => {
setPrivacy(option.value as PrivacyType);
setIsPrivacyDropdownOpen(false);
}}
>
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Publish Time */}
<div className="social-posting-field">
<label className="social-posting-label">
<span className="required">*</span>
</label>
<div className="social-posting-radio-group">
<label className="social-posting-radio">
<input
type="radio"
name="publishTime"
value="now"
checked={publishTime === 'now'}
onChange={() => setPublishTime('now')}
/>
<span className="radio-label"> </span>
</label>
<label className="social-posting-radio">
<input
type="radio"
name="publishTime"
value="schedule"
checked={publishTime === 'schedule'}
onChange={() => setPublishTime('schedule')}
disabled
/>
<span className="radio-label disabled"> ( )</span>
</label>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="social-posting-footer">
<p className="social-posting-footer-note">
<a href="#" className="social-posting-link"> </a> .
</p>
<div className="social-posting-actions">
<button
className="social-posting-btn cancel"
onClick={handleClose}
disabled={isPosting}
>
</button>
<button
className="social-posting-btn submit"
onClick={handlePost}
disabled={isPosting || socialAccounts.length === 0 || !title.trim()}
>
{isPosting ? '게시 중...' : '게시'}
</button>
</div>
</div>
</div>
</div>
{uploadProgressModalElement}
</>
);
};
export default SocialPostingModal;

View File

@ -0,0 +1,162 @@
import React from 'react';
export type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed';
interface UploadProgressModalProps {
isOpen: boolean;
onClose: () => void;
status: UploadStatus;
progress: number;
videoTitle: string;
channelName: string;
youtubeUrl?: string;
errorMessage?: string;
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
isOpen,
onClose,
status,
progress,
videoTitle,
channelName,
youtubeUrl,
errorMessage,
}) => {
if (!isOpen) return null;
const getStatusText = () => {
switch (status) {
case 'pending':
return '업로드 준비 중...';
case 'uploading':
return '업로드 중...';
case 'completed':
return '업로드 완료!';
case 'failed':
return '업로드 실패';
default:
return '처리 중...';
}
};
const getStatusIcon = () => {
switch (status) {
case 'pending':
case 'uploading':
return (
<div className="upload-progress-spinner">
<svg viewBox="0 0 50 50" className="upload-spinner-svg">
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
</svg>
</div>
);
case 'completed':
return (
<div className="upload-progress-icon success">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
);
case 'failed':
return (
<div className="upload-progress-icon error">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
);
default:
return null;
}
};
const canClose = status === 'completed' || status === 'failed';
return (
<div className="upload-progress-overlay">
<div className="upload-progress-modal">
{/* Header */}
<div className="upload-progress-header">
<h2 className="upload-progress-title">YouTube </h2>
{canClose && (
<button className="upload-progress-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
{/* Content */}
<div className="upload-progress-content">
{/* Status Icon */}
{getStatusIcon()}
{/* Status Text */}
<p className={`upload-progress-status ${status}`}>{getStatusText()}</p>
{/* Progress Bar (only for pending/uploading) */}
{(status === 'pending' || status === 'uploading') && (
<div className="upload-progress-bar-container">
<div
className="upload-progress-bar-fill"
style={{ width: `${progress}%` }}
/>
<span className="upload-progress-percent">{progress}%</span>
</div>
)}
{/* Video Info */}
<div className="upload-progress-info">
<div className="upload-progress-info-row">
<span className="upload-progress-label"> </span>
<span className="upload-progress-value">{videoTitle}</span>
</div>
<div className="upload-progress-info-row">
<span className="upload-progress-label"></span>
<span className="upload-progress-value">{channelName}</span>
</div>
</div>
{/* Error Message */}
{status === 'failed' && errorMessage && (
<div className="upload-progress-error">
<p>{errorMessage}</p>
</div>
)}
{/* Success: YouTube Link */}
{status === 'completed' && youtubeUrl && (
<a
href={youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="upload-progress-youtube-link"
>
<img src="/assets/images/social-youtube.png" alt="YouTube" className="upload-youtube-icon" />
YouTube
</a>
)}
</div>
{/* Footer */}
<div className="upload-progress-footer">
{canClose ? (
<button className="upload-progress-btn primary" onClick={onClose}>
{status === 'completed' ? '확인' : '닫기'}
</button>
) : (
<p className="upload-progress-note"> . .</p>
)}
</div>
</div>
</div>
);
};
export default UploadProgressModal;

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getVideosList, deleteVideo } from '../../utils/api'; import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
interface ADO2ContentsPageProps { interface ADO2ContentsPageProps {
onBack: () => void; onBack: () => void;
@ -17,8 +18,10 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
const [hasPrev, setHasPrev] = useState(false); const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const pageSize = 12; const pageSize = 12;
@ -31,6 +34,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
setError(null); setError(null);
try { try {
const response = await getVideosList(page, pageSize); const response = await getVideosList(page, pageSize);
console.log('[ADO2] API response:', response);
console.log('[ADO2] First video item:', response.items[0]);
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외) // result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== ''); const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos); setVideos(validVideos);
@ -83,11 +88,23 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
} }
}; };
const handleDeleteClick = (taskId: string) => { const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(taskId); setDeleteTargetId(videoId);
setDeleteModalOpen(true); setDeleteModalOpen(true);
}; };
const handleUploadClick = (video: VideoListItem) => {
console.log('[ADO2] Upload clicked - video object:', video);
console.log('[ADO2] video.video_id:', video.video_id);
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => { const handleDeleteCancel = () => {
setDeleteModalOpen(false); setDeleteModalOpen(false);
setDeleteTargetId(null); setDeleteTargetId(null);
@ -102,7 +119,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영) // 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해 // fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
// 삭제된 항목이 다시 나타날 수 있기 때문 // 삭제된 항목이 다시 나타날 수 있기 때문
setVideos(prev => prev.filter(video => video.task_id !== deleteTargetId)); setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1)); setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false); setDeleteModalOpen(false);
@ -197,9 +214,20 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
</svg> </svg>
<span></span> <span></span>
</button> </button>
<button
className="content-upload-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
title="소셜 미디어에 업로드"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button <button
className="content-delete-btn" className="content-delete-btn"
onClick={() => handleDeleteClick(video.task_id)} onClick={() => handleDeleteClick(video.video_id)}
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/> <path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
@ -259,6 +287,13 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
</div> </div>
</div> </div>
)} )}
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
/>
</div> </div>
); );
}; };

View File

@ -9,7 +9,7 @@ interface AssetManagementSectionProps {
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => { const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
return ( return (
<div className="flex w-full h-screen bg-[#0d1416] text-white overflow-hidden"> <div className="flex w-full h-screen bg-[#002224] text-white overflow-hidden">
<Sidebar /> <Sidebar />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<AssetManagementContent onBack={onBack} onNext={() => {}} /> <AssetManagementContent onBack={onBack} onNext={() => {}} />

View File

@ -362,7 +362,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
if (!youtubeAccount) return; if (!youtubeAccount) return;
try { try {
await disconnectSocialAccount('youtube'); await disconnectSocialAccount(youtubeAccount.id);
setYoutubeAccount(null); setYoutubeAccount(null);
setSelectedSocials(prev => prev.filter(s => s !== 'Youtube')); setSelectedSocials(prev => prev.filter(s => s !== 'Youtube'));
} catch (error) { } catch (error) {
@ -412,6 +412,29 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}; };
// 소셜 채널에 배포
const handleDeploy = () => {
if (selectedSocials.length === 0) {
alert('배포할 소셜 채널을 선택해주세요.');
return;
}
if (!videoUrl) {
alert('영상이 아직 준비되지 않았습니다.');
return;
}
// 선택된 채널 중 YouTube가 있고 연결된 경우
if (selectedSocials.includes('Youtube') && youtubeAccount) {
// TODO: YouTube 업로드 API 호출
alert(`YouTube 채널 "${youtubeAccount.display_name}"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)`);
return;
}
// 다른 플랫폼 선택 시
alert('선택한 소셜 채널에 배포 기능이 준비 중입니다.');
};
const handleRetry = () => { const handleRetry = () => {
hasStartedGeneration.current = false; hasStartedGeneration.current = false;
setVideoStatus('idle'); setVideoStatus('idle');
@ -615,6 +638,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="sharing-actions"> <div className="sharing-actions">
<button <button
onClick={handleDeploy}
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'} disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
className="btn-completion-deploy" className="btn-completion-deploy"
> >

View File

@ -8,6 +8,7 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent'; import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent'; import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage'; import ADO2ContentsPage from './ADO2ContentsPage';
import MyInfoContent from './MyInfoContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse } from '../../types/api'; import { ImageItem, CrawlingResponse } from '../../types/api';
@ -355,6 +356,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onBack={() => setActiveItem('새 프로젝트 만들기')} onBack={() => setActiveItem('새 프로젝트 만들기')}
/> />
); );
case '내 정보':
return <MyInfoContent />;
case '새 프로젝트 만들기': case '새 프로젝트 만들기':
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시 // 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) { if (wizardStep === 0 || wizardStep === -1) {
@ -379,8 +382,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤 // 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0; const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠 // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || isBrandAnalysis; const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || isBrandAnalysis;
// 브랜드 분석일 때는 전체 화면 스크롤 // 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) { if (isBrandAnalysis) {
@ -397,7 +400,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
return ( return (
<div className={`flex w-full bg-[#0d1416] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}> <div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
)} )}

View File

@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount } from '../../utils/api';
import { SocialAccount } from '../../types/api';
type TabType = 'basic' | 'payment' | 'business';
const MyInfoContent: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('business');
const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
const [isConnecting, setIsConnecting] = useState<string | null>(null);
// 소셜 계정 목록 로드
useEffect(() => {
loadSocialAccounts();
}, []);
const loadSocialAccounts = async () => {
setIsLoadingAccounts(true);
try {
const response = await getSocialAccounts();
setSocialAccounts(response.accounts || []);
} catch (error) {
console.error('Failed to load social accounts:', error);
} finally {
setIsLoadingAccounts(false);
}
};
// YouTube 연결
const handleYoutubeConnect = async () => {
setIsConnecting('youtube');
try {
const response = await getYouTubeConnectUrl();
window.location.href = response.auth_url;
} catch (error) {
console.error('Failed to get YouTube connect URL:', error);
setIsConnecting(null);
}
};
// 소셜 계정 연결 해제 (특정 계정 ID로)
const handleDisconnectAccount = async (accountId: number) => {
try {
await disconnectSocialAccount(accountId);
setSocialAccounts(prev => prev.filter(acc => acc.id !== accountId));
} catch (error) {
console.error('Failed to disconnect:', error);
}
};
// 플랫폼별 연결된 모든 계정 가져오기
const getConnectedAccounts = (platform: string) => {
return socialAccounts.filter(acc => acc.platform === platform && acc.is_active);
};
const youtubeAccounts = getConnectedAccounts('youtube');
const instagramAccounts = getConnectedAccounts('instagram');
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [
{ id: 'basic' as TabType, label: '기본 정보' },
{ id: 'payment' as TabType, label: '결제 정보' },
{ id: 'business' as TabType, label: '내 비즈니스 & 소셜 채널 관리' },
];
return (
<main className="myinfo-page">
<h1 className="myinfo-title"> </h1>
{/* 탭 네비게이션 */}
<div className="myinfo-tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`myinfo-tab ${activeTab === tab.id ? 'active' : ''}`}
>
{tab.label}
</button>
))}
</div>
{/* 탭 컨텐츠 */}
<div className="myinfo-content">
{activeTab === 'basic' && (
<div className="myinfo-section">
<p className="myinfo-placeholder"> .</p>
</div>
)}
{activeTab === 'payment' && (
<div className="myinfo-section">
<p className="myinfo-placeholder"> .</p>
</div>
)}
{activeTab === 'business' && (
<>
{/* 내 비즈니스 섹션 */}
<div className="myinfo-section">
<h2 className="myinfo-section-title"> </h2>
<div className="myinfo-business-card">
<h3 className="myinfo-business-empty-title"> </h3>
<p className="myinfo-business-empty-desc">
URL ,
</p>
<div className="myinfo-business-input-row">
<input
type="text"
value={businessUrl}
onChange={(e) => setBusinessUrl(e.target.value)}
placeholder="네이버 지도 URL 입력"
className="myinfo-business-input"
/>
<button className="myinfo-business-submit">
</button>
</div>
</div>
</div>
{/* 소셜 채널 섹션 */}
<div className="myinfo-section">
<h2 className="myinfo-section-title"> </h2>
{/* 연결 버튼들 (가로 배치) */}
<div className="myinfo-social-buttons">
<button
onClick={handleYoutubeConnect}
disabled={isConnecting === 'youtube'}
className={`myinfo-social-btn ${youtubeAccounts.length > 0 ? 'connected' : ''}`}
>
<img src="/assets/images/social-youtube.png" alt="YouTube" className="myinfo-social-btn-icon" />
<span>{isConnecting === 'youtube' ? '연결 중...' : 'YouTube 연결'}</span>
</button>
<button
disabled
className="myinfo-social-btn disabled"
>
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
<span>Instagram </span>
</button>
</div>
{/* 연결된 계정 목록 */}
{hasConnectedAccounts && (
<div className="myinfo-connected-accounts">
{/* YouTube 계정들 */}
{youtubeAccounts.map(account => (
<div key={account.id} className="myinfo-connected-card">
<div className="myinfo-connected-info">
{account.profile_image ? (
<img
src={account.profile_image}
alt={account.display_name}
className="myinfo-connected-avatar"
/>
) : (
<div className="myinfo-connected-icon">
<img src="/assets/images/social-youtube.png" alt="YouTube" />
</div>
)}
<div className="myinfo-connected-text">
<span className="myinfo-connected-channel">{account.display_name}</span>
<span className="myinfo-connected-platform">YouTube</span>
</div>
</div>
<div className="myinfo-connected-actions">
<span className="myinfo-connected-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<button
onClick={() => handleDisconnectAccount(account.id)}
className="myinfo-connected-disconnect"
>
</button>
</div>
</div>
))}
{/* Instagram 계정들 */}
{instagramAccounts.map(account => (
<div key={account.id} className="myinfo-connected-card">
<div className="myinfo-connected-info">
{account.profile_image ? (
<img
src={account.profile_image}
alt={account.display_name}
className="myinfo-connected-avatar"
/>
) : (
<div className="myinfo-connected-icon">
<img src="/assets/images/social-instagram.png" alt="Instagram" />
</div>
)}
<div className="myinfo-connected-text">
<span className="myinfo-connected-channel">{account.display_name}</span>
<span className="myinfo-connected-platform">Instagram</span>
</div>
</div>
<div className="myinfo-connected-actions">
<span className="myinfo-connected-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<button
onClick={() => handleDisconnectAccount(account.id)}
className="myinfo-connected-disconnect"
>
</button>
</div>
</div>
))}
</div>
)}
{isLoadingAccounts && (
<p className="myinfo-loading"> ...</p>
)}
</div>
</>
)}
</div>
</main>
);
};
export default MyInfoContent;

View File

@ -245,6 +245,7 @@ export interface UserMeResponse {
// 비디오 목록 아이템 // 비디오 목록 아이템
export interface VideoListItem { export interface VideoListItem {
video_id: number;
store_name: string; store_name: string;
region: string; region: string;
task_id: string; task_id: string;
@ -303,3 +304,43 @@ export interface SocialDisconnectResponse {
message: string; message: string;
} }
// ============================================
// Social Upload Types (YouTube Upload)
// ============================================
// 소셜 업로드 요청
export interface SocialUploadRequest {
video_id: number; // 아카이브의 비디오 ID
social_account_id: number; // 선택된 채널 ID
title: string; // 최대 100자
description: string; // 최대 5000자
tags: string[]; // 태그 배열
privacy_status: 'public' | 'unlisted' | 'private'; // 공개 범위
scheduled_at: string | null; // ISO 형식 또는 null (즉시 게시)
}
// 소셜 업로드 응답
export interface SocialUploadResponse {
success: boolean;
upload_id: string;
status: 'pending' | 'uploading' | 'completed' | 'failed';
message: string;
}
// 소셜 업로드 상태 조회 응답
export interface SocialUploadStatusResponse {
success: boolean;
upload_id: number;
video_id: number;
platform: string;
status: 'pending' | 'uploading' | 'completed' | 'failed';
upload_progress: number; // 업로드 진행률 (0-100)
title?: string; // 영상 제목
platform_video_id?: string; // 완료 시 플랫폼 비디오 ID
platform_url?: string; // 완료 시 플랫폼 URL
error_message?: string | null; // 실패 시 에러 메시지
retry_count?: number;
created_at?: string;
uploaded_at?: string;
}

View File

@ -22,6 +22,9 @@ import {
SocialAccountsResponse, SocialAccountsResponse,
SocialAccountResponse, SocialAccountResponse,
SocialDisconnectResponse, SocialDisconnectResponse,
SocialUploadRequest,
SocialUploadResponse,
SocialUploadStatusResponse,
} 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';
@ -295,9 +298,9 @@ export async function getVideosList(page: number = 1, pageSize: number = 10): Pr
return response.json(); return response.json();
} }
// 비디오 삭제 API // 비디오 삭제 API (개별 비디오 삭제)
export async function deleteVideo(taskId: string): Promise<void> { export async function deleteVideo(videoId: number): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/archive/videos/delete/${taskId}`, { const response = await authenticatedFetch(`${API_URL}/archive/videos/${videoId}`, {
method: 'DELETE', method: 'DELETE',
}); });
@ -713,9 +716,9 @@ export async function getSocialAccountByPlatform(platform: 'youtube' | 'instagra
return response.json(); return response.json();
} }
// 소셜 계정 연결 해제 // 소셜 계정 연결 해제 (계정 ID로)
export async function disconnectSocialAccount(platform: 'youtube' | 'instagram' | 'facebook'): Promise<SocialDisconnectResponse> { export async function disconnectSocialAccount(accountId: number): Promise<SocialDisconnectResponse> {
const response = await authenticatedFetch(`${API_URL}/social/oauth/${platform}/disconnect`, { const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts/${accountId}`, {
method: 'DELETE', method: 'DELETE',
}); });
@ -725,3 +728,74 @@ export async function disconnectSocialAccount(platform: 'youtube' | 'instagram'
return response.json(); return response.json();
} }
// ============================================
// Social Upload API (YouTube Video Upload)
// ============================================
// YouTube 영상 업로드 시작
export async function uploadToSocial(request: SocialUploadRequest): Promise<SocialUploadResponse> {
const response = await authenticatedFetch(`${API_URL}/social/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 업로드 상태 조회
export async function getUploadStatus(uploadId: string): Promise<SocialUploadStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/social/upload/${uploadId}/status`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 업로드 완료까지 폴링 (5분 타임아웃, 2초 간격)
const UPLOAD_POLL_TIMEOUT = 5 * 60 * 1000; // 5분
const UPLOAD_POLL_INTERVAL = 2000; // 2초
export async function waitForUploadComplete(
uploadId: string,
onStatusChange?: (status: string, progress?: number) => void
): Promise<SocialUploadStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SocialUploadStatusResponse> => {
// 5분 타임아웃 체크
if (Date.now() - startTime > UPLOAD_POLL_TIMEOUT) {
throw new Error('TIMEOUT');
}
try {
const response = await getUploadStatus(uploadId);
onStatusChange?.(response.status, response.upload_progress);
if (response.status === 'completed') {
return response;
} else if (response.status === 'failed') {
throw new Error(response.error_message || '업로드에 실패했습니다.');
}
// pending, uploading은 대기 후 재시도
await new Promise(resolve => setTimeout(resolve, UPLOAD_POLL_INTERVAL));
return poll();
} catch (error) {
throw error;
}
};
return poll();
}