Merge branch 'autoSeo'
commit
05dc2e1cc2
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect } from '../utils/api';
|
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
|
|
||||||
|
|
@ -43,6 +43,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false);
|
const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false);
|
||||||
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
|
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
|
||||||
|
const [isLoadingAutoDescription, setIsLoadingAutoDescription] = useState(false);
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -75,12 +76,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
// 비디오 정보로 기본 제목 설정
|
loadAutocomplete();
|
||||||
if (video) {
|
// // 비디오 정보로 기본 제목 설정
|
||||||
const date = new Date(video.created_at);
|
// if (video) {
|
||||||
const formattedDate = `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
// const date = new Date(video.created_at);
|
||||||
setTitle(`${video.store_name} ${formattedDate}`);
|
// const formattedDate = `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||||
}
|
// setTitle(`${video.store_name} ${formattedDate}`);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}, [isOpen, video]);
|
}, [isOpen, video]);
|
||||||
|
|
||||||
|
|
@ -105,6 +107,30 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAutocomplete = async () => {
|
||||||
|
if (!video?.task_id) return;
|
||||||
|
|
||||||
|
setIsLoadingAutoDescription(true);
|
||||||
|
try {
|
||||||
|
const requestPayload = {
|
||||||
|
task_id : video.task_id,
|
||||||
|
};
|
||||||
|
// Call autoSEO API
|
||||||
|
console.log('[Upload] Request payload:', requestPayload);
|
||||||
|
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||||
|
|
||||||
|
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
|
||||||
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load autocomplete:', error);
|
||||||
|
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAutoDescription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePost = async () => {
|
const handlePost = async () => {
|
||||||
if (!selectedChannel || !title.trim() || !video) {
|
if (!selectedChannel || !title.trim() || !video) {
|
||||||
alert(t('social.channelAndTitleRequired'));
|
alert(t('social.channelAndTitleRequired'));
|
||||||
|
|
@ -251,248 +277,254 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="social-posting-overlay" onClick={handleClose}>
|
<div className="social-posting-overlay" onClick={handleClose}>
|
||||||
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="social-posting-header">
|
<div className="social-posting-header">
|
||||||
<h2 className="social-posting-title">{t('social.title')}</h2>
|
<h2 className="social-posting-title">{t('social.title')}</h2>
|
||||||
<button className="social-posting-close" onClick={handleClose}>
|
<button className="social-posting-close" onClick={handleClose}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right: Form */}
|
{/* Content */}
|
||||||
<div className="social-posting-form">
|
<div className="social-posting-content">
|
||||||
{/* Video Info */}
|
{/* Left: Video Preview */}
|
||||||
<div className="social-posting-video-info">
|
<div className="social-posting-preview">
|
||||||
<span className="social-posting-label-badge">{t('social.postNumber')}</span>
|
<div className="social-posting-video-container">
|
||||||
<span className="social-posting-add-btn">+</span>
|
<video
|
||||||
|
src={video.result_movie_url}
|
||||||
|
className="social-posting-video"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="social-posting-video-meta">
|
{/* Right: Form */}
|
||||||
<p className="social-posting-video-title">
|
<div className="social-posting-form">
|
||||||
{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}
|
{/* Video Info */}
|
||||||
</p>
|
<div className="social-posting-video-info">
|
||||||
<p className="social-posting-video-specs">{t('social.videoSpecs')}</p>
|
<span className="social-posting-label-badge">{t('social.postNumber')}</span>
|
||||||
</div>
|
<span className="social-posting-add-btn">+</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Channel Selector - Custom Dropdown */}
|
<div className="social-posting-video-meta">
|
||||||
<div className="social-posting-field">
|
<p className="social-posting-video-title">
|
||||||
<label className="social-posting-label">
|
{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}
|
||||||
{t('social.channelLabel')} <span className="required">*</span>
|
</p>
|
||||||
</label>
|
<p className="social-posting-video-specs">{t('social.videoSpecs')}</p>
|
||||||
{isLoadingAccounts ? (
|
</div>
|
||||||
<div className="social-posting-loading">{t('social.loadingAccounts')}</div>
|
|
||||||
) : socialAccounts.length === 0 ? (
|
{/* Channel Selector - Custom Dropdown */}
|
||||||
<div className="social-posting-no-accounts">
|
<div className="social-posting-field">
|
||||||
<p>{t('social.noAccounts')}</p>
|
<label className="social-posting-label">
|
||||||
<p className="social-posting-no-accounts-hint">{t('social.noAccountsHint')}</p>
|
{t('social.channelLabel')} <span className="required">*</span>
|
||||||
</div>
|
</label>
|
||||||
) : (
|
{isLoadingAccounts ? (
|
||||||
<div className="social-posting-channel-dropdown" ref={channelDropdownRef}>
|
<div className="social-posting-loading">{t('social.loadingAccounts')}</div>
|
||||||
|
) : socialAccounts.length === 0 ? (
|
||||||
|
<div className="social-posting-no-accounts">
|
||||||
|
<p>{t('social.noAccounts')}</p>
|
||||||
|
<p className="social-posting-no-accounts-hint">{t('social.noAccountsHint')}</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">
|
||||||
|
{t('social.postTitleLabel')} <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')}
|
||||||
|
// placeholder={t('social.postTitlePlaceholder')}
|
||||||
|
className="social-posting-input"
|
||||||
|
maxLength={100}
|
||||||
|
disabled={isLoadingAutoDescription}
|
||||||
|
/>
|
||||||
|
<span className="social-posting-char-count">{title.length}/100</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="social-posting-field">
|
||||||
|
<label className="social-posting-label">{t('social.postContentLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))}
|
||||||
|
// placeholder={t('social.postContentPlaceholder')}
|
||||||
|
className="social-posting-textarea"
|
||||||
|
maxLength={5000}
|
||||||
|
rows={4}
|
||||||
|
disabled={isLoadingAutoDescription}
|
||||||
|
/>
|
||||||
|
<span className="social-posting-char-count">{description.length}/5,000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="social-posting-field">
|
||||||
|
<label className="social-posting-label">{t('social.tagsLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))}
|
||||||
|
// placeholder={t('social.tagsPlaceholder')}
|
||||||
|
className="social-posting-input"
|
||||||
|
maxLength={500}
|
||||||
|
disabled={isLoadingAutoDescription}
|
||||||
|
/>
|
||||||
|
<span className="social-posting-char-count">{tags.length}/500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy - Custom Dropdown */}
|
||||||
|
<div className="social-posting-field">
|
||||||
|
<label className="social-posting-label">
|
||||||
|
{t('social.privacyLabel')} <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="social-posting-channel-dropdown" ref={privacyDropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`social-posting-channel-trigger ${isChannelDropdownOpen ? 'open' : ''}`}
|
className={`social-posting-channel-trigger ${isPrivacyDropdownOpen ? 'open' : ''}`}
|
||||||
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
onClick={() => setIsPrivacyDropdownOpen(!isPrivacyDropdownOpen)}
|
||||||
>
|
>
|
||||||
<div className="social-posting-channel-selected">
|
<div className="social-posting-channel-selected">
|
||||||
{selectedAccount && (
|
<span>{selectedPrivacyOption?.label}</span>
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={getPlatformIcon(selectedAccount.platform)}
|
|
||||||
alt={selectedAccount.platform}
|
|
||||||
className="social-posting-channel-icon"
|
|
||||||
/>
|
|
||||||
<span>{selectedAccount.display_name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
<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"/>
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isChannelDropdownOpen && (
|
{isPrivacyDropdownOpen && (
|
||||||
<div className="social-posting-channel-menu">
|
<div className="social-posting-channel-menu">
|
||||||
{socialAccounts.map(account => (
|
{privacyOptions.map(option => (
|
||||||
<button
|
<button
|
||||||
key={account.id}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={`social-posting-channel-option ${selectedChannel === account.platform_user_id ? 'selected' : ''}`}
|
className={`social-posting-channel-option ${privacy === option.value ? 'selected' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedChannel(account.platform_user_id);
|
setPrivacy(option.value as PrivacyType);
|
||||||
setIsChannelDropdownOpen(false);
|
setIsPrivacyDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<span>{option.label}</span>
|
||||||
src={getPlatformIcon(account.platform)}
|
|
||||||
alt={account.platform}
|
|
||||||
className="social-posting-channel-option-icon"
|
|
||||||
/>
|
|
||||||
<span>{account.display_name}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="social-posting-field">
|
|
||||||
<label className="social-posting-label">
|
|
||||||
{t('social.postTitleLabel')} <span className="required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={t('social.postTitlePlaceholder')}
|
|
||||||
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">{t('social.postContentLabel')}</label>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder={t('social.postContentPlaceholder')}
|
|
||||||
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">{t('social.tagsLabel')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tags}
|
|
||||||
onChange={(e) => setTags(e.target.value)}
|
|
||||||
placeholder={t('social.tagsPlaceholder')}
|
|
||||||
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">
|
|
||||||
{t('social.privacyLabel')} <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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Publish Time */}
|
{/* Publish Time */}
|
||||||
<div className="social-posting-field">
|
<div className="social-posting-field">
|
||||||
<label className="social-posting-label">
|
<label className="social-posting-label">
|
||||||
{t('social.publishTimeLabel')} <span className="required">*</span>
|
{t('social.publishTimeLabel')} <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">{t('social.publishNow')}</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">{t('social.publishSchedule')}</span>
|
|
||||||
</label>
|
</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">{t('social.publishNow')}</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">{t('social.publishSchedule')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="social-posting-footer">
|
<div className="social-posting-footer">
|
||||||
<p className="social-posting-footer-note">
|
<p className="social-posting-footer-note">
|
||||||
{t('social.footerNote', { link: '' })}<a href="#" className="social-posting-link">{t('social.footerNoteLink')}</a>
|
{t('social.footerNote', { link: '' })}<a href="#" className="social-posting-link">{t('social.footerNoteLink')}</a>
|
||||||
</p>
|
</p>
|
||||||
<div className="social-posting-actions">
|
<div className="social-posting-actions">
|
||||||
<button
|
<button
|
||||||
className="social-posting-btn cancel"
|
className="social-posting-btn cancel"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
disabled={isPosting}
|
disabled={isPosting}
|
||||||
>
|
>
|
||||||
{t('social.cancel')}
|
{t('social.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="social-posting-btn submit"
|
className="social-posting-btn submit"
|
||||||
onClick={handlePost}
|
onClick={handlePost}
|
||||||
disabled={isPosting || socialAccounts.length === 0 || !title.trim()}
|
disabled={isPosting || socialAccounts.length === 0 || !title.trim()}
|
||||||
>
|
>
|
||||||
{isPosting ? t('social.posting') : t('social.post')}
|
{isPosting ? t('social.posting') : t('social.post')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{uploadProgressModalElement}
|
||||||
{uploadProgressModalElement}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@
|
||||||
"selectChannel": "Please select a channel.",
|
"selectChannel": "Please select a channel.",
|
||||||
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
|
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
|
||||||
"uploadStartFailed": "Failed to start upload.",
|
"uploadStartFailed": "Failed to start upload.",
|
||||||
"uploadFailed": "Upload failed."
|
"uploadFailed": "Upload failed.",
|
||||||
|
"autoSeoTitle": "This will be automatically generated. please wait.",
|
||||||
|
"autoSeoDescription": "This will be automatically generated. please wait.",
|
||||||
|
"autoSeoTags": "This will be automatically generated. please wait."
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube Upload",
|
"title": "YouTube Upload",
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@
|
||||||
"selectChannel": "채널을 선택해주세요.",
|
"selectChannel": "채널을 선택해주세요.",
|
||||||
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
||||||
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
||||||
"uploadFailed": "업로드에 실패했습니다."
|
"uploadFailed": "업로드에 실패했습니다.",
|
||||||
|
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
|
||||||
|
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
|
||||||
|
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube 업로드",
|
"title": "YouTube 업로드",
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,18 @@ export interface SocialDisconnectResponse {
|
||||||
// Social Upload Types (YouTube Upload)
|
// Social Upload Types (YouTube Upload)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
// 유튜브 SEO Description 자동완성 요청
|
||||||
|
export interface YTAutoSeoRequest {
|
||||||
|
task_id: string; // 아카이브의 비디오 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유튜브 SEO Description 자동완성 응답
|
||||||
|
export interface YTAutoSeoResponse {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// 소셜 업로드 요청
|
// 소셜 업로드 요청
|
||||||
export interface SocialUploadRequest {
|
export interface SocialUploadRequest {
|
||||||
video_id: number; // 아카이브의 비디오 ID
|
video_id: number; // 아카이브의 비디오 ID
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import {
|
||||||
SocialUploadResponse,
|
SocialUploadResponse,
|
||||||
SocialUploadStatusResponse,
|
SocialUploadStatusResponse,
|
||||||
TokenExpiredErrorResponse,
|
TokenExpiredErrorResponse,
|
||||||
|
YTAutoSeoRequest,
|
||||||
|
YTAutoSeoResponse,
|
||||||
} 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';
|
||||||
|
|
@ -790,6 +792,20 @@ export async function disconnectSocialAccount(accountId: number): Promise<Social
|
||||||
// Social Upload API (YouTube Video Upload)
|
// Social Upload API (YouTube Video Upload)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
// YouTube Description API
|
||||||
|
export async function getAutoSeoYoutube(request: YTAutoSeoRequest): Promise<YTAutoSeoResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/social/seo/youtube`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleSocialResponse(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube 영상 업로드 시작
|
// YouTube 영상 업로드 시작
|
||||||
export async function uploadToSocial(request: SocialUploadRequest): Promise<SocialUploadResponse> {
|
export async function uploadToSocial(request: SocialUploadRequest): Promise<SocialUploadResponse> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/social/upload`, {
|
const response = await authenticatedFetch(`${API_URL}/social/upload`, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue