import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } 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 MiniCalendar: React.FC<{ selectedDate: Date | null; onSelect: (date: Date) => void; minDate?: Date; }> = ({ selectedDate, onSelect, minDate }) => { const today = new Date(); const [viewYear, setViewYear] = useState(selectedDate?.getFullYear() ?? today.getFullYear()); const [viewMonth, setViewMonth] = useState(selectedDate?.getMonth() ?? today.getMonth()); const DAY_LABELS = ['일', '월', '화', '수', '목', '금', '토']; const firstDay = new Date(viewYear, viewMonth, 1).getDay(); const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate(); const cells: { date: Date; currentMonth: boolean }[] = []; for (let i = firstDay - 1; i >= 0; i--) { cells.push({ date: new Date(viewYear, viewMonth - 1, daysInPrevMonth - i), currentMonth: false }); } for (let d = 1; d <= daysInMonth; d++) { cells.push({ date: new Date(viewYear, viewMonth, d), currentMonth: true }); } while (cells.length % 7 !== 0) { cells.push({ date: new Date(viewYear, viewMonth + 1, cells.length - daysInMonth - firstDay + 1), currentMonth: false }); } const isSameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const isDisabled = (date: Date) => { if (!minDate) return false; const min = new Date(minDate); min.setHours(0,0,0,0); const d = new Date(date); d.setHours(0,0,0,0); return d < min; }; const prevMonth = () => { if (viewMonth === 0) { setViewYear(y => y - 1); setViewMonth(11); } else setViewMonth(m => m - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewYear(y => y + 1); setViewMonth(0); } else setViewMonth(m => m + 1); }; return (
{viewYear}년 {viewMonth + 1}월
{DAY_LABELS.map(d => (
{d}
))} {cells.map((cell, i) => { const disabled = isDisabled(cell.date); const isSelected = selectedDate ? isSameDay(cell.date, selectedDate) : false; const isToday = isSameDay(cell.date, today); return ( ); })}
); }; const SocialPostingModal: React.FC = ({ isOpen, onClose, video }) => { const { t } = useTranslation(); const [socialAccounts, setSocialAccounts] = useState([]); const [selectedChannel, setSelectedChannel] = useState(''); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [tags, setTags] = useState(''); const [privacy, setPrivacy] = useState('public'); const [publishTime, setPublishTime] = useState('now'); const [scheduledDate, setScheduledDate] = useState(null); const [scheduledHour, setScheduledHour] = useState(12); const [scheduledMinute, setScheduledMinute] = useState(0); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [isPosting, setIsPosting] = useState(false); const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false); const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false); const [isLoadingAutoDescription, setIsLoadingAutoDescription] = useState(false); const [isHorizontalVideo, setIsHorizontalVideo] = useState(false); const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null); const channelDropdownRef = useRef(null); const privacyDropdownRef = useRef(null); // Upload progress modal state const [showUploadProgress, setShowUploadProgress] = useState(false); const [uploadStatus, setUploadStatus] = useState('pending'); const [uploadProgress, setUploadProgress] = useState(0); const [uploadYoutubeUrl, setUploadYoutubeUrl] = useState(); const [uploadErrorMessage, setUploadErrorMessage] = useState(); // 업로드 정보 (모달이 닫힌 후에도 유지) const [uploadVideoTitle, setUploadVideoTitle] = useState(''); const [uploadChannelName, setUploadChannelName] = useState(''); // 드롭다운 외부 클릭 시 닫기 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) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen]); // 소셜 계정 로드 useEffect(() => { if (isOpen) { loadSocialAccounts(); loadAutocomplete(); } }, [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) { if (error instanceof TokenExpiredError) { alert(t('social.youtubeExpiredAlert')); handleSocialReconnect(error.reconnectUrl); return; } console.error('Failed to load social accounts:', error); } finally { setIsLoadingAccounts(false); } }; 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 () => { if (!selectedChannel || !title.trim() || !video) { alert(t('social.channelAndTitleRequired')); return; } if (publishTime === 'schedule' && !scheduledDate) { alert('게시 날짜를 선택해주세요.'); return; } if (publishTime === 'schedule' && scheduledDate) { const dt = new Date(scheduledDate); dt.setHours(scheduledHour, scheduledMinute, 0, 0); if (dt <= new Date()) { alert('현재 시각 이후의 시간을 선택해주세요.'); return; } } const selectedAcc = socialAccounts.find(acc => acc.platform_user_id === selectedChannel); if (!selectedAcc) { alert(t('social.selectChannel')); return; } // video.video_id 검증 - 반드시 존재해야 함 if (!video.video_id) { console.error('Video object missing video_id:', video); alert(t('social.invalidVideoInfo')); 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 로그 let scheduledAt: string | null = null; if (publishTime === 'schedule' && scheduledDate) { const dt = new Date(scheduledDate); dt.setHours(scheduledHour, scheduledMinute, 0, 0); // 로컬 시간 기준으로 전송 (Z 없이) const pad = (n: number) => String(n).padStart(2, '0'); scheduledAt = `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}:00`; } 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: scheduledAt, }; console.log('[Upload] Request payload:', requestPayload); // Call upload API const uploadResponse = await uploadToSocial(requestPayload); if (!uploadResponse.success) { throw new Error(uploadResponse.message || t('social.uploadStartFailed')); } if (publishTime === 'schedule') { // 예약 업로드: 폴링 없이 바로 완료 처리 setUploadStatus('completed'); setUploadProgress(100); onClose(); resetForm(); } else { // 즉시 업로드: 완료될 때까지 폴링 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) { if (error instanceof TokenExpiredError) { setShowUploadProgress(false); alert(t('social.youtubeExpiredAlert')); handleSocialReconnect(error.reconnectUrl); return; } console.error('Upload failed:', error); setUploadStatus('failed'); setUploadErrorMessage(error instanceof Error ? error.message : t('social.uploadFailed')); } finally { setIsPosting(false); } }; const resetForm = () => { setTitle(''); setDescription(''); setTags(''); setPrivacy('public'); setPublishTime('now'); setScheduledDate(null); setScheduledHour(12); setScheduledMinute(0); 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: t('social.privacyPublic') }, { value: 'unlisted', label: t('social.privacyUnlisted') }, { value: 'private', label: t('social.privacyPrivate') } ]; const selectedPrivacyOption = privacyOptions.find(opt => opt.value === privacy); // Always render UploadProgressModal, even when main modal is closed const uploadProgressModalElement = ( ); if (!isOpen || !video) { // Still render upload progress modal even when main modal is closed return showUploadProgress ? uploadProgressModalElement : null; } return ( <>
e.stopPropagation()}> {/* Header */}

{t('social.title')}

{/* Content */}
{/* Left: Video Preview */}
{/* Right: Form */}
{/* Video Info */} {/*
{t('social.postNumber')} +
*/}

{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}

{videoMeta ? `${videoMeta.width}×${videoMeta.height} · ${Math.floor(videoMeta.duration / 60)}:${String(Math.floor(videoMeta.duration % 60)).padStart(2, '0')}` : t('social.videoSpecs')}

{/* Channel Selector - Custom Dropdown */}
{isLoadingAccounts ? (
{t('social.loadingAccounts')}
) : socialAccounts.length === 0 ? (

{t('social.noAccounts')}

{t('social.noAccountsHint')}

) : (
{isChannelDropdownOpen && (
{socialAccounts.map(account => ( ))}
)}
)}
{/* Title */}
setTitle(e.target.value)} placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')} // placeholder={t('social.postTitlePlaceholder')} className="social-posting-input" maxLength={100} disabled={isLoadingAutoDescription} /> {title.length}/100
{/* Description */}