o2o-castad-frontend/src/components/SocialPostingModal.tsx
김성경 82b9fef3a1 chore: 튜토리얼 오버레이와 관련 문구를 제거
랜딩·업로드에서 자동 안내가 더 이상 뜨지 않게 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:31:44 +09:00

851 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { useOverlayClose } from '../hooks/useOverlayClose';
interface SocialPostingModalProps {
isOpen: boolean;
onClose: () => void;
video: VideoListItem | null;
onGoToCalendar?: () => void;
}
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,
onGoToCalendar,
}) => {
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);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
const SEO_CACHE_TTL = 50 * 60 * 1000;
// 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>('');
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
// 드롭다운 외부 클릭 시 닫기
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]);
// 소셜 계정 로드 + SEO 자동 채움
useEffect(() => {
if (!isOpen) {
loadedForTaskIdRef.current = null;
return;
}
loadSocialAccounts();
if (video?.title) {
setTitle(video.title);
setDescription(video.description || '');
setTags((video.hashtags || []).join(','));
setIsLoadingAutoDescription(false);
return;
}
const now = Date.now();
const seoKey =
video?.type === 'ssul'
? `ssul:${video.video_id}`
: video?.task_id || null;
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
if (seoKey && (seoKey !== loadedForTaskIdRef.current || expired)) {
loadedForTaskIdRef.current = seoKey;
loadedAtRef.current = now;
loadAutocomplete(seoKey);
} else if (seoKey) {
const cached = seoCache.current.get(seoKey);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
}
}, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
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) {
const lastUsed = localStorage.getItem('lastUsedSocialChannel');
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
setSelectedChannel(defaultAccount.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);
}
};
/**
* 제목·설명·태그 자동 채움.
*
* `seoKey` 는 호출부가 만든 캐시 키다(ADO2=task_id, 썰박스=`ssul:{id}`).
* 백엔드에 보낼 식별자는 종류마다 다르다 — ADO2 는 task_id(UUID), 썰박스는
* ssul_content.id 문자열이며, 서버가 content_type 으로 어느 테이블을 볼지 가른다.
*/
const loadAutocomplete = async (seoKey: string) => {
if (!video) return;
const isSsul = video.type === 'ssul';
if (!isSsul && !video.task_id) return;
setIsLoadingAutoDescription(true);
try {
const requestPayload = {
content_type: video.type,
task_id: isSsul ? String(video.video_id) : video.task_id,
};
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
seoCache.current.set(seoKey, {
title: autoSeoResponse.title || '',
description: autoSeoResponse.description || '',
tags: 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);
setUploadIsScheduled(publishTime === 'schedule');
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 가 겹치는 엉뚱한 ADO2 영상이 업로드된다.
content_type: video.type,
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'));
}
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
if (publishTime === 'schedule') {
// 예약 업로드: 폴링 없이 바로 완료 처리
setUploadStatus('completed');
setUploadProgress(100);
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
onClose();
resetForm();
} else {
// 즉시 업로드: 완료될 때까지 폴링
const result = await waitForUploadComplete(
uploadResponse.upload_id.toString(),
(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('');
setUploadIsScheduled(false);
setUploadScheduledAt(null);
};
const handleClose = () => {
onClose();
resetForm();
};
const overlayCloseHandlers = useOverlayClose(handleClose);
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={uploadIsScheduled}
scheduledAt={uploadScheduledAt}
onGoToCalendar={onGoToCalendar}
/>
);
if (!isOpen || !video) {
return (
<>
{showUploadProgress && uploadProgressModalElement}
</>
);
}
return (
<>
<div className="social-posting-overlay" {...overlayCloseHandlers}>
<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>
</>
) : (
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</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>
<div className="seo-input-wrapper">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postTitlePlaceholder')}
className="social-posting-input"
maxLength={100}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTitle')}</span>
)}
</div>
<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>
<div className="seo-input-wrapper">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postContentPlaceholder')}
className="social-posting-textarea"
maxLength={5000}
rows={4}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoDescription')}</span>
)}
</div>
<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>
<div className="seo-input-wrapper">
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.tagsPlaceholder')}
className="social-posting-input"
maxLength={500}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTags')}</span>
)}
</div>
<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;