769 lines
31 KiB
TypeScript
769 lines
31 KiB
TypeScript
|
||
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 (
|
||
<div className="mini-calendar">
|
||
<div className="mini-calendar-header">
|
||
<button type="button" className="mini-calendar-nav" onClick={prevMonth}>‹</button>
|
||
<span className="mini-calendar-title">{viewYear}년 {viewMonth + 1}월</span>
|
||
<button type="button" className="mini-calendar-nav" onClick={nextMonth}>›</button>
|
||
</div>
|
||
<div className="mini-calendar-grid">
|
||
{DAY_LABELS.map(d => (
|
||
<div key={d} className="mini-calendar-day-label">{d}</div>
|
||
))}
|
||
{cells.map((cell, i) => {
|
||
const disabled = isDisabled(cell.date);
|
||
const isSelected = selectedDate ? isSameDay(cell.date, selectedDate) : false;
|
||
const isToday = isSameDay(cell.date, today);
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
disabled={disabled}
|
||
className={[
|
||
'mini-calendar-cell',
|
||
cell.currentMonth ? '' : 'other-month',
|
||
isSelected ? 'selected' : '',
|
||
isToday && !isSelected ? 'today' : '',
|
||
disabled ? 'disabled' : '',
|
||
].join(' ')}
|
||
onClick={() => !disabled && onSelect(cell.date)}
|
||
>
|
||
{cell.date.getDate()}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
video
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
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 [scheduledDate, setScheduledDate] = useState<Date | null>(null);
|
||
const [scheduledHour, setScheduledHour] = useState<number>(12);
|
||
const [scheduledMinute, setScheduledMinute] = useState<number>(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<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) {
|
||
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 = (
|
||
<UploadProgressModal
|
||
isOpen={showUploadProgress}
|
||
onClose={handleUploadProgressClose}
|
||
status={uploadStatus}
|
||
progress={uploadProgress}
|
||
videoTitle={uploadVideoTitle || title || (video?.store_name || '')}
|
||
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
||
youtubeUrl={uploadYoutubeUrl}
|
||
errorMessage={uploadErrorMessage}
|
||
isScheduled={publishTime === 'schedule'}
|
||
/>
|
||
);
|
||
|
||
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">{t('social.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 ${isHorizontalVideo ? 'horizontal' : 'vertical'}`}
|
||
controls
|
||
playsInline
|
||
onLoadedMetadata={(e) => {
|
||
const v = e.currentTarget;
|
||
setIsHorizontalVideo(v.videoWidth > v.videoHeight);
|
||
setVideoMeta({ width: v.videoWidth, height: v.videoHeight, duration: v.duration });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Form */}
|
||
<div className="social-posting-form">
|
||
{/* Video Info */}
|
||
{/* <div className="social-posting-video-info">
|
||
<span className="social-posting-label-badge">{t('social.postNumber')}</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">
|
||
{videoMeta
|
||
? `${videoMeta.width}×${videoMeta.height} · ${Math.floor(videoMeta.duration / 60)}:${String(Math.floor(videoMeta.duration % 60)).padStart(2, '0')}`
|
||
: t('social.videoSpecs')}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Channel Selector - Custom Dropdown */}
|
||
<div className="social-posting-field">
|
||
<label className="social-posting-label">
|
||
{t('social.channelLabel')} <span className="required">*</span>
|
||
</label>
|
||
{isLoadingAccounts ? (
|
||
<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
|
||
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">
|
||
{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')}
|
||
/>
|
||
<span className="radio-label">{t('social.publishSchedule')}</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* 시간 설정 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 (
|
||
<div className="schedule-datetime-picker">
|
||
<MiniCalendar
|
||
selectedDate={scheduledDate}
|
||
onSelect={handleDateSelect}
|
||
minDate={new Date()}
|
||
/>
|
||
<div className="schedule-time-picker">
|
||
<span className="schedule-time-label">시간 설정</span>
|
||
<div className="schedule-time-selects">
|
||
<select
|
||
className="schedule-time-select"
|
||
value={scheduledHour}
|
||
onChange={(e) => handleHourChange(Number(e.target.value))}
|
||
>
|
||
{Array.from({ length: 24 }, (_, i) => (
|
||
<option key={i} value={i} disabled={isTodaySelected && i < minHour}>
|
||
{String(i).padStart(2, '0')}시
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="schedule-time-select"
|
||
value={scheduledMinute}
|
||
onChange={(e) => setScheduledMinute(Number(e.target.value))}
|
||
>
|
||
{[0, 10, 20, 30, 40, 50].map(m => (
|
||
<option key={m} value={m} disabled={isTodaySelected && m < minMinuteForSelectedHour}>
|
||
{String(m).padStart(2, '0')}분
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{scheduledDate && (
|
||
<p className="schedule-datetime-preview">
|
||
{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')} 예약
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="social-posting-footer">
|
||
<p className="social-posting-footer-note">
|
||
{t('social.footerNote', { link: '' })}<a href="#" className="social-posting-link">{t('social.footerNoteLink')}</a>
|
||
</p>
|
||
<div className="social-posting-actions">
|
||
<button
|
||
className="social-posting-btn cancel"
|
||
onClick={handleClose}
|
||
disabled={isPosting}
|
||
>
|
||
{t('social.cancel')}
|
||
</button>
|
||
<button
|
||
className="social-posting-btn submit"
|
||
onClick={handlePost}
|
||
disabled={
|
||
isPosting ||
|
||
socialAccounts.length === 0 ||
|
||
!title.trim() ||
|
||
(publishTime === 'schedule' && !scheduledDate)
|
||
}
|
||
>
|
||
{isPosting ? t('social.posting') : t('social.post')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{uploadProgressModalElement}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default SocialPostingModal;
|