유튜브 업로드 기능 완료 .
parent
e1b860a15f
commit
fd9db31e52
|
|
@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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={() => {}} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue