Compare commits

...

2 Commits

Author SHA1 Message Date
dhlim 05dc2e1cc2 Merge branch 'autoSeo' 2026-02-25 04:14:06 +00:00
jaehwang 9df3fcbcd3 autoseo 추가 2026-02-25 04:07:23 +00:00
5 changed files with 283 additions and 217 deletions

View File

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

View File

@ -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",

View File

@ -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 업로드",

View File

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

View File

@ -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`, {