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 */}
{/* Tags */}
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}
/>
{tags.length}/500
{/* Privacy - Custom Dropdown */}
{isPrivacyDropdownOpen && (
{privacyOptions.map(option => (
))}
)}
{/* Publish Time */}
{/* 시간 설정 UI */}
{publishTime === 'schedule' && (() => {
const now = new Date();
const isTodaySelected = scheduledDate
? scheduledDate.getFullYear() === now.getFullYear() &&
scheduledDate.getMonth() === now.getMonth() &&
scheduledDate.getDate() === now.getDate()
: false;
// 오늘 선택 시: 현재 시각 기준으로 선택 가능한 최소 시간 계산
// 현재 분을 10분 단위 올림하여 최소 분 결정
const nowMinute = now.getMinutes();
const minMinuteForCurrentHour = Math.ceil((nowMinute + 1) / 10) * 10;
const minHour = isTodaySelected
? (minMinuteForCurrentHour >= 60 ? now.getHours() + 1 : now.getHours())
: 0;
// 선택된 시간이 오늘 최소 시간보다 작으면 분 옵션 제한
const minMinuteForSelectedHour = isTodaySelected && scheduledHour === now.getHours()
? minMinuteForCurrentHour >= 60 ? 0 : minMinuteForCurrentHour
: 0;
const handleDateSelect = (date: Date) => {
const selectedIsToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
setScheduledDate(date);
// 오늘을 선택했을 때 현재 설정된 시간이 과거라면 최솟값으로 보정
if (selectedIsToday) {
const minMin = Math.ceil((now.getMinutes() + 1) / 10) * 10;
const minH = minMin >= 60 ? now.getHours() + 1 : now.getHours();
const adjMin = minMin >= 60 ? 0 : minMin;
if (scheduledHour < minH || (scheduledHour === minH && scheduledMinute < adjMin)) {
setScheduledHour(minH);
setScheduledMinute(adjMin);
}
}
};
const handleHourChange = (h: number) => {
setScheduledHour(h);
// 시간 변경 시 분이 과거가 되면 최솟값으로 보정
if (isTodaySelected && h === now.getHours()) {
const minMin = Math.ceil((now.getMinutes() + 1) / 10) * 10;
const adjMin = minMin >= 60 ? 0 : minMin;
if (scheduledMinute < adjMin) setScheduledMinute(adjMin);
}
};
return (
시간 설정
{scheduledDate && (
{scheduledDate.getFullYear()}.{String(scheduledDate.getMonth()+1).padStart(2,'0')}.{String(scheduledDate.getDate()).padStart(2,'0')} {String(scheduledHour).padStart(2,'0')}:{String(scheduledMinute).padStart(2,'0')} 예약
)}
);
})()}
{/* Footer */}
{t('social.footerNote', { link: '' })}{t('social.footerNoteLink')}
{uploadProgressModalElement}
>
);
};
export default SocialPostingModal;