유튜브 업로드 기능 완료 .
parent
e1b860a15f
commit
fd9db31e52
|
|
@ -8,7 +8,8 @@
|
|||
"Bash(npm run build:*)",
|
||||
"Bash(python3:*)",
|
||||
"mcp__figma__get_figma_data",
|
||||
"mcp__figma__download_figma_images"
|
||||
"mcp__figma__download_figma_images",
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"><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> },
|
||||
{ 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: 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> },
|
||||
{ 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: 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> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
|
||||
interface ADO2ContentsPageProps {
|
||||
onBack: () => void;
|
||||
|
|
@ -17,8 +18,10 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
const [hasPrev, setHasPrev] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
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 [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
|
|
@ -31,6 +34,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
setError(null);
|
||||
try {
|
||||
const response = await getVideosList(page, pageSize);
|
||||
console.log('[ADO2] API response:', response);
|
||||
console.log('[ADO2] First video item:', response.items[0]);
|
||||
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
|
||||
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
|
||||
setVideos(validVideos);
|
||||
|
|
@ -83,11 +88,23 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (taskId: string) => {
|
||||
setDeleteTargetId(taskId);
|
||||
const handleDeleteClick = (videoId: number) => {
|
||||
setDeleteTargetId(videoId);
|
||||
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 = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
|
|
@ -102,7 +119,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
|
||||
// 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));
|
||||
|
||||
setDeleteModalOpen(false);
|
||||
|
|
@ -197,9 +214,20 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
</svg>
|
||||
<span>다운로드</span>
|
||||
</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
|
||||
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">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
|
|
@ -259,6 +287,13 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 소셜 미디어 업로드 모달 */}
|
||||
<SocialPostingModal
|
||||
isOpen={uploadModalOpen}
|
||||
onClose={handleUploadModalClose}
|
||||
video={uploadTargetVideo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface AssetManagementSectionProps {
|
|||
|
||||
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
|
||||
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 />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AssetManagementContent onBack={onBack} onNext={() => {}} />
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
if (!youtubeAccount) return;
|
||||
|
||||
try {
|
||||
await disconnectSocialAccount('youtube');
|
||||
await disconnectSocialAccount(youtubeAccount.id);
|
||||
setYoutubeAccount(null);
|
||||
setSelectedSocials(prev => prev.filter(s => s !== 'Youtube'));
|
||||
} 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 = () => {
|
||||
hasStartedGeneration.current = false;
|
||||
setVideoStatus('idle');
|
||||
|
|
@ -615,6 +638,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
|
||||
<div className="sharing-actions">
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
||||
className="btn-completion-deploy"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import DashboardContent from './DashboardContent';
|
|||
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||
import UrlInputContent from './UrlInputContent';
|
||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||
import MyInfoContent from './MyInfoContent';
|
||||
import LoadingSection from '../Analysis/LoadingSection';
|
||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||
import { ImageItem, CrawlingResponse } from '../../types/api';
|
||||
|
|
@ -355,6 +356,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||
/>
|
||||
);
|
||||
case '내 정보':
|
||||
return <MyInfoContent />;
|
||||
case '새 프로젝트 만들기':
|
||||
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
||||
if (wizardStep === 0 || wizardStep === -1) {
|
||||
|
|
@ -379,8 +382,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
|
||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠
|
||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || isBrandAnalysis;
|
||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || isBrandAnalysis;
|
||||
|
||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||
if (isBrandAnalysis) {
|
||||
|
|
@ -397,7 +400,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
}
|
||||
|
||||
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 && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -245,6 +245,7 @@ export interface UserMeResponse {
|
|||
|
||||
// 비디오 목록 아이템
|
||||
export interface VideoListItem {
|
||||
video_id: number;
|
||||
store_name: string;
|
||||
region: string;
|
||||
task_id: string;
|
||||
|
|
@ -303,3 +304,43 @@ export interface SocialDisconnectResponse {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import {
|
|||
SocialAccountsResponse,
|
||||
SocialAccountResponse,
|
||||
SocialDisconnectResponse,
|
||||
SocialUploadRequest,
|
||||
SocialUploadResponse,
|
||||
SocialUploadStatusResponse,
|
||||
} from '../types/api';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 비디오 삭제 API
|
||||
export async function deleteVideo(taskId: string): Promise<void> {
|
||||
const response = await authenticatedFetch(`${API_URL}/archive/videos/delete/${taskId}`, {
|
||||
// 비디오 삭제 API (개별 비디오 삭제)
|
||||
export async function deleteVideo(videoId: number): Promise<void> {
|
||||
const response = await authenticatedFetch(`${API_URL}/archive/videos/${videoId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
|
|
@ -713,9 +716,9 @@ export async function getSocialAccountByPlatform(platform: 'youtube' | 'instagra
|
|||
return response.json();
|
||||
}
|
||||
|
||||
// 소셜 계정 연결 해제
|
||||
export async function disconnectSocialAccount(platform: 'youtube' | 'instagram' | 'facebook'): Promise<SocialDisconnectResponse> {
|
||||
const response = await authenticatedFetch(`${API_URL}/social/oauth/${platform}/disconnect`, {
|
||||
// 소셜 계정 연결 해제 (계정 ID로)
|
||||
export async function disconnectSocialAccount(accountId: number): Promise<SocialDisconnectResponse> {
|
||||
const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
|
|
@ -725,3 +728,74 @@ export async function disconnectSocialAccount(platform: 'youtube' | 'instagram'
|
|||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue