Compare commits

..

5 Commits

16 changed files with 1854 additions and 571 deletions

View File

@ -10,7 +10,8 @@
"mcp__figma__get_figma_data",
"mcp__figma__download_figma_images",
"Bash(npx tsc:*)",
"Bash(grep:*)"
"Bash(grep:*)",
"mcp__claude_ai_Figma__get_design_context"
]
}
}

776
index.css

File diff suppressed because it is too large Load Diff

View File

@ -135,19 +135,15 @@ const App: React.FC = () => {
url.searchParams.delete('refresh_token');
window.history.replaceState({}, document.title, url.pathname);
// 로그인 성공 - 분석 데이터 유무에 따라 분기
// 비로그인 상태에서 URL 입력 후 브랜드 분석을 본 경우 → 바로 generation_flow로 (URL 입력 스킵)
// 홈에서 바로 로그인한 경우 → generation_flow로 (URL 입력 필요)
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
// 분석 데이터가 있으면 바로 에셋 관리로
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
} else {
// 분석 데이터가 없으면 URL 입력부터 시작하도록 generation_flow로
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
} catch (err) {
console.error('Token callback failed:', err);
alert(t('app.loginFailed'));
@ -335,6 +331,12 @@ const App: React.FC = () => {
const handleToLogin = async () => {
// 이미 로그인된 상태면 바로 generation_flow로 이동
if (isLoggedIn()) {
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
return;

View File

@ -83,6 +83,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
];

View File

@ -26,6 +26,90 @@ const getPlatformIcon = (platform: string) => {
}
};
// 미니 캘린더 컴포넌트
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,
@ -39,11 +123,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
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 channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
@ -72,6 +160,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
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) {
@ -137,6 +235,20 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
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'));
@ -170,6 +282,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
.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,
@ -177,7 +298,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
description: description.trim(),
tags: tagsArray,
privacy_status: privacy,
scheduled_at: publishTime === 'now' ? null : null,
scheduled_at: scheduledAt,
};
console.log('[Upload] Request payload:', requestPayload);
@ -188,23 +309,31 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
}
// Poll for upload completion
const result = await waitForUploadComplete(
uploadResponse.upload_id,
(status, progress) => {
setUploadStatus(status as UploadStatus);
setUploadProgress(progress || 0);
}
);
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);
// Upload completed successfully
setUploadStatus('completed');
setUploadProgress(100);
setUploadYoutubeUrl(result.platform_url);
// Close the posting modal (keep progress modal open)
onClose();
resetForm();
// Close the posting modal (keep progress modal open)
onClose();
resetForm();
}
} catch (error) {
if (error instanceof TokenExpiredError) {
setShowUploadProgress(false);
@ -226,6 +355,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setTags('');
setPrivacy('public');
setPublishTime('now');
setScheduledDate(null);
setScheduledHour(12);
setScheduledMinute(0);
setSelectedChannel('');
setIsChannelDropdownOpen(false);
setIsPrivacyDropdownOpen(false);
@ -267,6 +399,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
channelName={uploadChannelName || selectedAccount?.display_name || ''}
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
isScheduled={publishTime === 'schedule'}
/>
);
@ -297,9 +430,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<div className="social-posting-video-container">
<video
src={video.result_movie_url}
className="social-posting-video"
className={`social-posting-video ${isHorizontalVideo ? 'horizontal' : 'vertical'}`}
controls
playsInline
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsHorizontalVideo(v.videoWidth > v.videoHeight);
}}
/>
</div>
</div>
@ -491,11 +628,105 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
value="schedule"
checked={publishTime === 'schedule'}
onChange={() => setPublishTime('schedule')}
disabled
/>
<span className="radio-label disabled">{t('social.publishSchedule')}</span>
<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>
@ -516,7 +747,12 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<button
className="social-posting-btn submit"
onClick={handlePost}
disabled={isPosting || socialAccounts.length === 0 || !title.trim()}
disabled={
isPosting ||
socialAccounts.length === 0 ||
!title.trim() ||
(publishTime === 'schedule' && !scheduledDate)
}
>
{isPosting ? t('social.posting') : t('social.post')}
</button>

View File

@ -13,6 +13,7 @@ interface UploadProgressModalProps {
channelName: string;
youtubeUrl?: string;
errorMessage?: string;
isScheduled?: boolean;
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
@ -24,6 +25,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
channelName,
youtubeUrl,
errorMessage,
isScheduled = false,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
@ -35,7 +37,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
case 'uploading':
return t('upload.statusUploading');
case 'completed':
return t('upload.statusCompleted');
return isScheduled ? t('upload.statusScheduled') : t('upload.statusCompleted');
case 'failed':
return t('upload.statusFailed');
default:
@ -83,7 +85,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
<div className="upload-progress-modal">
{/* Header */}
<div className="upload-progress-header">
<h2 className="upload-progress-title">{t('upload.title')}</h2>
<h2 className="upload-progress-title">{isScheduled && status === 'completed' ? t('upload.titleScheduled') : t('upload.title')}</h2>
{canClose && (
<button className="upload-progress-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">

View File

@ -60,9 +60,11 @@
},
"upload": {
"title": "YouTube Upload",
"titleScheduled": "YouTube Schedule",
"statusPending": "Preparing upload...",
"statusUploading": "Uploading...",
"statusCompleted": "Upload complete!",
"statusScheduled": "Scheduled!",
"statusFailed": "Upload failed",
"statusDefault": "Processing...",
"videoTitleLabel": "Video Title",
@ -127,6 +129,11 @@
"imageUpload": "Image Upload",
"dragAndDrop": "Drag and drop\nimages to upload",
"videoRatio": "Video Ratio",
"minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts",
"youtubeVideo": "YouTube Video",
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploading": "Uploading...",
"nextStep": "Next Step"

View File

@ -60,9 +60,11 @@
},
"upload": {
"title": "YouTube 업로드",
"titleScheduled": "YouTube 예약 등록",
"statusPending": "업로드 준비 중...",
"statusUploading": "업로드 중...",
"statusCompleted": "업로드 완료!",
"statusScheduled": "예약 완료!",
"statusFailed": "업로드 실패",
"statusDefault": "처리 중...",
"videoTitleLabel": "영상 제목",
@ -127,6 +129,11 @@
"imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 드래그하여\n업로드",
"videoRatio": "영상 비율",
"minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠",
"youtubeVideo": "유튜브 일반",
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중...",
"nextStep": "다음 단계"

View File

@ -207,25 +207,25 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.download')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
title={t('ado2Contents.uploadToSocial')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"

View File

@ -6,6 +6,7 @@ import { uploadImages } from '../../utils/api';
interface AssetManagementContentProps {
onNext: (imageTaskId: string) => void;
onBack?: () => void;
imageList: ImageItem[];
onRemoveImage: (index: number) => void;
onAddImages: (files: File[]) => void;
@ -13,20 +14,22 @@ interface AssetManagementContentProps {
type VideoRatio = 'vertical' | 'horizontal';
const IMAGES_PER_PAGE = 12;
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
onNext,
onBack,
imageList,
onRemoveImage,
onAddImages,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const imageListRef = useRef<HTMLDivElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
// Load video ratio from localStorage on mount
useEffect(() => {
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
if (savedRatio === 'vertical' || savedRatio === 'horizontal') {
@ -34,7 +37,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
}, []);
// Save video ratio to localStorage when it changes
const handleVideoRatioChange = (ratio: VideoRatio) => {
setVideoRatio(ratio);
localStorage.setItem('castad_video_ratio', ratio);
@ -51,7 +53,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
setUploadError(null);
try {
// URL 이미지와 파일 이미지 분리
const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
.map((item) => ({ url: item.url }));
@ -60,7 +61,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
.filter((item: ImageItem): item is ImageItem & { type: 'file' } => item.type === 'file')
.map((item) => item.file);
// 이미지가 하나라도 있으면 업로드
if (urlImages.length > 0 || fileImages.length > 0) {
const response = await uploadImages(urlImages, fileImages);
onNext(response.task_id);
@ -73,11 +73,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
};
const handleImageListWheel = (e: React.WheelEvent) => {
// 이 영역 안에서는 항상 스크롤 이벤트 전파 차단
e.stopPropagation();
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@ -86,14 +81,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter(file =>
const files = Array.from(e.dataTransfer.files).filter((file: File) =>
file.type.startsWith('image/')
);
if (files.length > 0) {
onAddImages(files);
}
if (files.length > 0) onAddImages(files);
};
const handleFileSelect = () => {
@ -108,104 +99,132 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
};
const visibleImages = imageList.slice(0, displayCount);
const hasMore = imageList.length > displayCount;
return (
<main className="page-container">
{/* Title */}
<h1 className="asset-title">{t('assetManagement.title')}</h1>
<main className="asset-page">
{/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header">
{onBack && (
<button onClick={onBack} className="asset-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
{t('assetManagement.back')}
</button>
)}
</div>
{/* Main Content Container */}
<div className="asset-container">
{/* Left Column - Selected Images */}
<div className="asset-column asset-column-left">
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
<div
ref={imageListRef}
onWheel={handleImageListWheel}
className="asset-image-list"
>
{imageList.length > 0 ? (
<div className="asset-image-grid">
{imageList.map((item, i) => (
<div key={i} className="asset-image-item">
<img
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
/>
{item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
)}
<button
onClick={() => onRemoveImage(i)}
className="asset-image-remove"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
{/* Single Scrollable Content Area */}
<div className="asset-scroll-area">
<h1 className="asset-title">{t('assetManagement.title')}</h1>
<div className="asset-container">
{/* Left Column - Selected Images */}
<div className="asset-column asset-column-left">
<div className="asset-section-header">
<div className="asset-section-header-left">
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
<span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
</div>
) : null}
</div>
</div>
{/* Right Column - Upload and Video Ratio */}
<div className="asset-column asset-column-right">
{/* Image Upload Section */}
<div className="asset-upload-section">
<h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3>
<div
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
className="asset-upload-zone"
>
<p className="asset-upload-text">
{t('assetManagement.dragAndDrop').split('\n').map((line, i) => (
<React.Fragment key={i}>{i > 0 && <br/>}{line}</React.Fragment>
))}
</p>
<button onClick={handleFileSelect} className="asset-mobile-upload-btn">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<line x1="8" y1="2" x2="8" y2="14" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="2" y1="8" x2="14" y2="8" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
{t('assetManagement.imageUpload')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<div className="asset-image-list">
{visibleImages.length > 0 && (
<div className="asset-image-grid">
{visibleImages.map((item, i) => (
<div key={i} className="asset-image-item">
<img
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
/>
{item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
)}
<button
onClick={() => onRemoveImage(i)}
className="asset-image-remove"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
{hasMore && (
<button
className="asset-load-more"
onClick={() => setDisplayCount(prev => prev + IMAGES_PER_PAGE)}
>
{t('assetManagement.loadMore')}
</button>
)}
</div>
{/* Video Ratio Section */}
<div className="asset-ratio-section">
<h3 className="asset-section-title">{t('assetManagement.videoRatio')}</h3>
<div className="asset-ratio-buttons">
<button
onClick={() => handleVideoRatioChange('vertical')}
className={`asset-ratio-button ${videoRatio === 'vertical' ? 'active' : ''}`}
{/* Right Column - Upload and Video Ratio */}
<div className="asset-column asset-column-right">
{/* Image Upload Section (desktop only) */}
<div className="asset-upload-section">
<h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3>
<div
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
className="asset-upload-zone"
>
<div className="asset-ratio-icon asset-ratio-icon-vertical">
<div className="asset-ratio-box"></div>
</div>
<span>9:16</span>
</button>
<button
onClick={() => handleVideoRatioChange('horizontal')}
className={`asset-ratio-button ${videoRatio === 'horizontal' ? 'active' : ''}`}
>
<div className="asset-ratio-icon asset-ratio-icon-horizontal">
<div className="asset-ratio-box"></div>
</div>
<span>16:9</span>
</button>
<p className="asset-upload-text">
{t('assetManagement.dragAndDrop').split('\n').map((line, i) => (
<React.Fragment key={i}>{i > 0 && <br/>}{line}</React.Fragment>
))}
</p>
</div>
</div>
{/* Video Ratio Section */}
<div className="asset-ratio-section">
<h3 className="asset-section-title">{t('assetManagement.videoRatio')}</h3>
<div className="asset-ratio-buttons">
<button
onClick={() => handleVideoRatioChange('vertical')}
className={`asset-ratio-button ${videoRatio === 'vertical' ? 'active' : ''}`}
>
<div className="asset-ratio-icon asset-ratio-icon-vertical">
<div className="asset-ratio-box"></div>
</div>
<span className="asset-ratio-label">9:16</span>
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeShorts')}</span>
</button>
<button
onClick={() => handleVideoRatioChange('horizontal')}
className={`asset-ratio-button ${videoRatio === 'horizontal' ? 'active' : ''}`}
>
<div className="asset-ratio-icon asset-ratio-icon-horizontal">
<div className="asset-ratio-box"></div>
</div>
<span className="asset-ratio-label">16:9</span>
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeVideo')}</span>
</button>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Button */}
<div className="asset-bottom">
{/* Fixed Footer - 다음 단계 버튼 */}
<div className="asset-sticky-footer">
{uploadError && (
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
)}
@ -217,6 +236,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
{isUploading ? t('assetManagement.uploading') : t('assetManagement.nextStep')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
</main>
);
};

View File

@ -37,12 +37,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [renderProgress, setRenderProgress] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [showControls, setShowControls] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
const hasStartedGeneration = useRef(false);
const hideControlsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false);
@ -284,62 +279,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
}
}, []);
const togglePlayPause = () => {
if (!videoRef.current || !videoUrl) return;
if (isPlaying) {
videoRef.current.pause();
setShowControls(true);
if (hideControlsTimer.current) clearTimeout(hideControlsTimer.current);
} else {
videoRef.current.play();
hideControlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
setIsPlaying(!isPlaying);
};
const handleTimeUpdate = () => {
if (videoRef.current && videoRef.current.duration > 0) {
setProgress((videoRef.current.currentTime / videoRef.current.duration) * 100);
}
};
const handleVideoEnded = () => {
setIsPlaying(false);
setProgress(0);
setShowControls(true);
};
const resetHideControlsTimer = () => {
if (hideControlsTimer.current) {
clearTimeout(hideControlsTimer.current);
}
setShowControls(true);
if (isPlaying) {
hideControlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
};
const handleVideoMouseEnter = () => {
resetHideControlsTimer();
};
const handleVideoMouseMove = () => {
resetHideControlsTimer();
};
const handleVideoMouseLeave = () => {
if (isPlaying) {
if (hideControlsTimer.current) clearTimeout(hideControlsTimer.current);
hideControlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 1000);
}
};
const handleDownload = () => {
if (videoUrl) {
const link = document.createElement('a');
@ -374,7 +313,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
// 비디오 해상도 계산
const getVideoResolution = () => {
const savedRatio = localStorage.getItem('castad_video_ratio');
return savedRatio === 'horizontal' ? '1234×720' : '720×1234';
return savedRatio === 'horizontal' ? '1280×720' : '720×1280';
};
// 파일명 생성
@ -403,7 +342,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-grid">
{/* 왼쪽: 영상 */}
<div className="comp2-video-section">
<div className="comp2-video-wrapper" onMouseEnter={handleVideoMouseEnter} onMouseMove={handleVideoMouseMove} onMouseLeave={handleVideoMouseLeave}>
<div className="comp2-video-wrapper">
{isLoading ? (
<div className="comp2-video-loading">
<div className="loading-spinner">
@ -426,47 +365,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</button>
</div>
) : videoUrl ? (
<>
<video
ref={videoRef}
src={videoUrl}
className="comp2-video-player"
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onClick={togglePlayPause}
/>
<div className={`comp2-video-controls ${showControls ? 'visible' : 'hidden'}`}>
<div className="comp2-progress-bar">
<div className="comp2-progress-fill" style={{ width: `${progress}%` }}></div>
</div>
<div className="comp2-controls-row">
<button className="comp2-play-btn" onClick={togglePlayPause}>
{isPlaying ? (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</button>
<div className="comp2-volume-control">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
<div className="comp2-volume-bar">
<div className="comp2-volume-fill"></div>
</div>
</div>
<div className="comp2-time-display">
<span className="comp2-time-current">00:04</span>
<span className="comp2-time-divider">/</span>
<span className="comp2-time-total">00:34</span>
</div>
</div>
</div>
</>
<video
src={videoUrl}
className="comp2-video-player"
controls
playsInline
/>
) : (
<div className="comp2-video-placeholder"></div>
)}

View File

@ -0,0 +1,801 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { UploadHistoryItem } from '../../types/api';
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
// 날짜 유틸리티
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
type TabType = '전체' | '완료' | '예약' | '실패';
type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
const TAB_TO_API: Record<TabType, 'all' | 'completed' | 'scheduled' | 'failed'> = {
'전체': 'all',
'완료': 'completed',
'예약': 'scheduled',
'실패': 'failed',
};
interface CalendarDay {
date: number;
month: 'prev' | 'current' | 'next';
isToday?: boolean;
fullDate: Date;
}
interface DaySummary {
completed: number;
scheduled: number;
failed: number;
}
const MOBILE_BREAKPOINT = 768;
// 플랫폼 아이콘
const PlatformIcon: React.FC<{ platform: string; size?: number }> = ({ platform, size = 20 }) => {
const src = platform === 'youtube'
? '/assets/images/social-youtube.png'
: platform === 'instagram'
? '/assets/images/social-instagram.png'
: '/assets/images/social-youtube.png';
return <img src={src} alt={platform} style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }} />;
};
// 상태 도트 색상
const statusColor = (status: UploadStatus) => {
switch (status) {
case 'completed': return '#1ba64f';
case 'scheduled':
case 'pending': return '#2563eb';
case 'failed': return '#e15252';
default: return '#9bcacc';
}
};
// 상태 라벨
const statusLabel = (status: UploadStatus) => {
switch (status) {
case 'completed': return '완료';
case 'scheduled':
case 'pending': return '예약';
case 'failed': return '실패';
default: return status;
}
};
interface ContentCalendarContentProps {
onNavigate?: (id: string) => void;
}
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [activeTab, setActiveTab] = useState<TabType>('전체');
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
const [sheetOpen, setSheetOpen] = useState(false);
// allItems: 캘린더 도트용 (전체)
// panelItems: 오른쪽 패널용 (탭 필터)
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
const [panelItems, setPanelItems] = useState<UploadHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
const [panelLoading, setPanelLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const dateRefs = useRef<Record<string, HTMLDivElement | null>>({});
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const filterCancelled = (items: UploadHistoryItem[]) =>
items.filter(i => i.status !== 'cancelled');
// 캘린더용 전체 데이터 (year/month 기준)
const fetchAllHistory = useCallback(async () => {
setLoading(true);
try {
const res = await getUploadHistory('all', { year, month: month + 1, size: 100 });
if (res.items) setAllItems(filterCancelled(res.items));
} catch {
// 에러 무시
} finally {
setLoading(false);
}
}, [year, month]);
// 패널용 탭 필터 데이터 (year/month 기준)
const fetchPanelHistory = useCallback(async (tab: TabType) => {
setPanelLoading(true);
try {
const res = await getUploadHistory(TAB_TO_API[tab], { year, month: month + 1, size: 100 });
if (res.items) setPanelItems(filterCancelled(res.items));
} catch {
// 에러 무시
} finally {
setPanelLoading(false);
}
}, [year, month]);
useEffect(() => {
fetchAllHistory();
}, [fetchAllHistory]);
useEffect(() => {
fetchPanelHistory(activeTab);
}, [activeTab, fetchPanelHistory]);
const refreshAll = useCallback(() => {
fetchAllHistory();
fetchPanelHistory(activeTab);
}, [fetchAllHistory, fetchPanelHistory, activeTab]);
const handlePrevMonth = () => {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
};
const handleNextMonth = () => {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
};
// 날짜 문자열 (YYYY-MM-DD)
const toDateKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// 아이템 날짜 키 추출 (scheduled_at 또는 created_at)
const itemDateKey = (item: UploadHistoryItem) => {
const dateStr = item.scheduled_at || item.uploaded_at || item.created_at;
return dateStr ? dateStr.slice(0, 10) : '';
};
// 날짜별 요약 맵 (캘린더용 — allItems 전체 기준)
const daySummaryMap: Record<string, DaySummary> = {};
allItems.forEach(item => {
const key = itemDateKey(item);
if (!key) return;
if (!daySummaryMap[key]) daySummaryMap[key] = { completed: 0, scheduled: 0, failed: 0 };
if (item.status === 'completed') daySummaryMap[key].completed++;
else if (item.status === 'failed') daySummaryMap[key].failed++;
else daySummaryMap[key].scheduled++;
});
// 전체 통계 (allItems 기준)
const totalStats = allItems.reduce(
(acc: { scheduled: number; completed: number; failed: number }, item) => {
if (item.status === 'completed') acc.completed++;
else if (item.status === 'failed') acc.failed++;
else acc.scheduled++;
return acc;
},
{ scheduled: 0, completed: 0, failed: 0 }
);
// 패널 아이템은 서버에서 탭별로 이미 필터링된 panelItems 사용
// 날짜별로 그룹핑 (내림차순)
const groupedByDate: Record<string, UploadHistoryItem[]> = {};
panelItems.forEach(item => {
const key = itemDateKey(item);
if (!key) return;
if (!groupedByDate[key]) groupedByDate[key] = [];
groupedByDate[key].push(item);
});
const sortedDateKeys = Object.keys(groupedByDate).sort((a, b) => b.localeCompare(a));
// 날짜 클릭 시 패널 스크롤
const handleDateClick = (dateKey: string) => {
setSelectedDate(dateKey);
// 패널 내부 스크롤: panelRef 기준으로 offsetTop 계산
setTimeout(() => {
const el = dateRefs.current[dateKey];
const panel = panelRef.current;
if (el && panel) {
const panelTop = panel.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
panel.scrollTo({ top: offset, behavior: 'smooth' });
}
}, 50);
};
// 취소
const handleCancel = async (uploadId: number) => {
if (!window.confirm('예약을 취소하시겠습니까?')) return;
// 낙관적 업데이트: 먼저 UI에서 제거
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
try {
await cancelUpload(uploadId);
} catch {
// 실패 시 다시 불러오기
refreshAll();
alert('취소에 실패했습니다.');
}
};
// 재시도
const handleRetry = async (uploadId: number) => {
try {
await retryUpload(uploadId);
refreshAll();
} catch {
alert('재시도에 실패했습니다.');
}
};
// 시간 표시 (HH:MM)
const formatTime = (item: UploadHistoryItem) => {
const dateStr = item.scheduled_at || item.uploaded_at || item.created_at;
if (!dateStr) return '';
const d = new Date(dateStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// 날짜 표시 (M월 D)
const formatDateLabel = (key: string) => {
const [, m, d] = key.split('-');
return `${parseInt(m)}${parseInt(d)}`;
};
const buildCalendarDays = (): CalendarDay[] => {
const days: CalendarDay[] = [];
const firstDay = getFirstDayOfMonth(year, month);
const daysInCurrent = getDaysInMonth(year, month);
const daysInPrev = getDaysInMonth(year, month === 0 ? 11 : month - 1);
const prevYear = month === 0 ? year - 1 : year;
const prevMonth = month === 0 ? 11 : month - 1;
const nextYear = month === 11 ? year + 1 : year;
const nextMonth = month === 11 ? 0 : month + 1;
for (let i = firstDay - 1; i >= 0; i--) {
const d = daysInPrev - i;
days.push({ date: d, month: 'prev', fullDate: new Date(prevYear, prevMonth, d) });
}
for (let d = 1; d <= daysInCurrent; d++) {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
days.push({ date: d, month: 'current', isToday, fullDate: new Date(year, month, d) });
}
const remaining = 42 - days.length;
for (let d = 1; d <= remaining; d++) {
days.push({ date: d, month: 'next', fullDate: new Date(nextYear, nextMonth, d) });
}
return days;
};
const calendarDays = buildCalendarDays();
const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
const dayLabels = ['일','월','화','수','목','금','토'];
const tabs: TabType[] = ['전체','완료','예약','실패'];
// ── 캘린더 그리드 ──────────────────────────────────────────────
const renderCalendarGrid = () => {
const getDateLabel = (day: CalendarDay) => {
if (day.month === 'prev' && day.date === getDaysInMonth(year, month === 0 ? 11 : month - 1)) {
return `${month === 0 ? 12 : month}${day.date}`;
}
if (day.month === 'next' && day.date === 1) {
return `${month === 11 ? 1 : month + 2}${day.date}`;
}
return `${day.date}`;
};
return (
<div
style={{
border: '1px solid #379599',
borderRadius: 12,
overflow: 'hidden',
display: 'grid',
gridTemplateRows: '28px repeat(6, minmax(0, 1fr))',
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
flex: 1,
minHeight: isMobile ? 360 : 0,
}}
>
{/* 요일 헤더 */}
{dayLabels.map((day, idx) => (
<div
key={day}
style={{
backgroundColor: '#01393b',
borderRight: idx < 6 ? '1px solid #379599' : 'none',
borderBottom: '1px solid #379599',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
boxSizing: 'border-box',
}}
>
<span style={{
flex: 1,
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12, color: '#cee5e6',
letterSpacing: '-0.072px', textAlign: 'right',
}}>
{day}
</span>
</div>
))}
{/* 날짜 셀 */}
{calendarDays.map((day, globalIdx) => {
const colIdx = globalIdx % 7;
const rowIdx = Math.floor(globalIdx / 7);
const isLastRow = rowIdx === 5;
const isLastCol = colIdx === 6;
const isOtherMonth = day.month !== 'current';
const bgColor = isOtherMonth ? '#6ab0b3' : '#9bcacc';
const dateKey = toDateKey(day.fullDate);
const summary = daySummaryMap[dateKey];
const isSelected = selectedDate === dateKey;
const cellBorder = {
borderRight: isLastCol ? 'none' : '1px solid #379599',
borderBottom: isLastRow ? 'none' : '1px solid #379599',
};
return (
<div
key={globalIdx}
onClick={() => !isOtherMonth && handleDateClick(dateKey)}
style={{
backgroundColor: isSelected ? '#046266' : bgColor,
...cellBorder,
position: 'relative',
overflow: 'hidden',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
padding: 8,
gap: 4,
cursor: isOtherMonth ? 'default' : 'pointer',
}}
>
{/* 날짜 숫자 */}
{day.isToday ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}>
<div style={{
width: 24, height: 24,
backgroundColor: '#e15252', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12, color: '#ffffff', letterSpacing: '-0.072px', lineHeight: 1,
}}>
{day.date}
</span>
</div>
</div>
) : (
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12,
color: isOtherMonth ? '#067c80' : (isSelected ? '#e5f1f2' : '#01191a'),
letterSpacing: '-0.072px', textAlign: 'right', lineHeight: 1, flexShrink: 0,
}}>
{getDateLabel(day)}
</span>
)}
{/* 상태 도트 요약 */}
{summary && !isOtherMonth && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{summary.completed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.completed}
</span>
</div>
)}
{summary.scheduled > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.scheduled}
</span>
</div>
)}
{summary.failed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.failed}
</span>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
};
// ── 탭 바 ────────────────────────────────────────────────────
const renderTabs = () => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '6px 16px', borderRadius: 999, border: 'none', cursor: 'pointer',
backgroundColor: activeTab === tab ? '#067c80' : 'transparent',
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 16,
color: activeTab === tab ? '#ffffff' : '#9bcacc',
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}}
>
{tab}
</button>
))}
</div>
);
// ── 통계 바 ────────────────────────────────────────────────
const renderStats = () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{[
{ label: '예약', value: totalStats.scheduled },
{ label: '완료', value: totalStats.completed },
{ label: '실패', value: totalStats.failed },
].map((item, idx) => (
<React.Fragment key={item.label}>
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 14, color: '#9bcacc', lineHeight: '18px' }}>
{item.label}
</span>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 14, color: '#ffffff', lineHeight: '18px' }}>
{item.value}
</span>
</div>
</React.Fragment>
))}
</div>
);
// ── 월 네비게이션 ────────────────────────────────────────────
const renderMonthNav = () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<button
onClick={handlePrevMonth}
style={{
width: 34, height: 34, borderRadius: 8,
backgroundColor: 'rgba(148,251,224,0.11)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 8, flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="#e5f1f2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap',
}}>
{year} {monthNames[month]}
</p>
<button
onClick={handleNextMonth}
style={{
width: 34, height: 34, borderRadius: 8,
backgroundColor: 'rgba(148,251,224,0.11)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 8, flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M7.5 5L12.5 10L7.5 15" stroke="#e5f1f2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
// ── 업로드 아이템 카드 ────────────────────────────────────────
const renderItem = (item: UploadHistoryItem) => {
const isScheduled = item.status !== 'completed' && item.status !== 'failed';
const isFailed = item.status === 'failed';
return (
<div
key={item.upload_id}
style={{
backgroundColor: isFailed ? 'rgba(225,82,82,0.08)' : 'rgba(55,149,153,0.12)',
borderRadius: 12,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 8,
borderLeft: isFailed ? '3px solid #e15252' : isScheduled ? '3px solid #2563eb' : '3px solid #1ba64f',
}}
>
{/* 상단: 상태 + 시간 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
}} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}>
{statusLabel(item.status as UploadStatus)}
</span>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
}}>
{formatTime(item)}
</span>
</div>
{/* 채널 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlatformIcon platform={item.platform} size={18} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{item.title}
</span>
</div>
{/* 실패 메시지 */}
{isFailed && item.error_message && (
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 12,
color: '#e15252', lineHeight: 1.5, margin: 0,
}}>
{item.error_message}
</p>
)}
{/* 액션 버튼 */}
{(isScheduled || isFailed) && (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{isScheduled && (
<button
onClick={() => handleCancel(item.upload_id)}
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: '1px solid #379599', backgroundColor: 'transparent',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: '#9bcacc', cursor: 'pointer',
}}
>
</button>
)}
{isFailed && (
<button
onClick={() => handleRetry(item.upload_id)}
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: 'none', backgroundColor: '#a65eff',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: '#ffffff', cursor: 'pointer',
}}
>
</button>
)}
</div>
)}
</div>
);
};
// ── 패널 콘텐츠 ────────────────────────────────────────────
const renderPanelContent = () => {
if (panelLoading) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
);
}
if (sortedDateKeys.length === 0) {
return (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
}}>
</p>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
}}>
</p>
</div>
<button
onClick={() => onNavigate?.('ADO2 콘텐츠')}
style={{
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
backgroundColor: '#a65eff', cursor: 'pointer',
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#ffffff',
}}
>
ADO2
</button>
</div>
);
}
return (
<div
ref={panelRef}
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20 }}
>
{sortedDateKeys.map(dateKey => (
<div
key={dateKey}
ref={el => { dateRefs.current[dateKey] = el; }}
>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 14,
color: '#9bcacc', margin: '0 0 8px 0', letterSpacing: '-0.084px',
}}>
{formatDateLabel(dateKey)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{groupedByDate[dateKey].map(item => renderItem(item))}
</div>
</div>
))}
</div>
);
};
// ════════════════════════════════════════════════════════════════
// 모바일 레이아웃
// ════════════════════════════════════════════════════════════════
if (isMobile) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
paddingTop: 24, paddingBottom: 20, width: '100%',
boxSizing: 'border-box', position: 'relative',
}}>
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
</p>
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 16,
width: '100%', paddingLeft: 20, paddingRight: 20,
marginBottom: 16, boxSizing: 'border-box',
}}>
{renderMonthNav()}
{renderStats()}
</div>
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 360 }}>
{renderCalendarGrid()}
</div>
{/* 바텀시트 */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{
backgroundColor: '#01393b',
borderTop: '1px solid #046266',
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
}}>
<div
onClick={() => setSheetOpen(o => !o)}
style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }}
>
<div style={{ width: 49, height: 4, backgroundColor: '#9bcacc', borderRadius: 999 }} />
</div>
<div style={{
borderBottom: sheetOpen ? '1px solid #046266' : 'none',
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
}}>
{renderTabs()}
</div>
{sheetOpen && (
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
{renderPanelContent()}
</div>
)}
</div>
</div>
<div style={{ height: sheetOpen ? 516 : 116 }} />
</div>
);
}
// ════════════════════════════════════════════════════════════════
// 데스크탑 레이아웃
// ════════════════════════════════════════════════════════════════
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '80px 32px',
width: '100%',
minWidth: 1400,
height: '100%', boxSizing: 'border-box',
}}>
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px',
gap: 16,
width: '100%', maxWidth: 1440,
flex: 1, minHeight: 0,
}}>
{/* 캘린더 영역 */}
<div style={{
gridColumn: '1 / span 8',
backgroundColor: '#01393b',
borderRadius: 20, padding: 16,
display: 'flex', flexDirection: 'column', gap: 24,
overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', flexShrink: 0, width: '100%',
}}>
{renderMonthNav()}
{renderStats()}
</div>
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
) : renderCalendarGrid()}
</div>
{/* 오른쪽 패널 */}
<div style={{
gridColumn: '9 / span 1',
backgroundColor: '#01393b', borderRadius: 20,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{
borderBottom: '1px solid #046266',
height: 66, display: 'flex', alignItems: 'center',
padding: '0 16px', flexShrink: 0,
}}>
{renderTabs()}
</div>
{renderPanelContent()}
</div>
</div>
</div>
);
};
export default ContentCalendarContent;

View File

@ -10,6 +10,7 @@ import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage';
import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
@ -88,11 +89,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 초기 위저드 단계 결정
const getInitialWizardStep = (): number => {
// 저장된 단계가 있으면 사용
if (savedWizardStep !== null) {
return parseInt(savedWizardStep, 10);
const step = parseInt(savedWizardStep, 10);
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
// 로그인 후 재진입이므로 에셋 관리(1)부터 시작
const hasAnalysisData = initialAnalysisData || savedAnalysisData;
if (hasAnalysisData && step < 1) {
return 1;
}
return step;
}
// 분석 데이터가 있으면 에셋 관리(1)부터, 없으면 URL 입력(-2)부터
// 저장된 단계 없음: 분석 데이터가 있으면 에셋 관리(1), 없으면 URL 입력(-2)
const hasAnalysisData = initialAnalysisData || savedAnalysisData;
return hasAnalysisData ? 1 : -2;
};
@ -310,6 +317,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 기존 프로젝트 데이터 초기화
clearAllProjectStorage();
localStorage.removeItem(ANALYSIS_DATA_KEY);
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
localStorage.setItem(WIZARD_STEP_KEY, '-2');
setWizardStep(-2);
setSongTaskId(null);
setImageTaskId(null);
@ -354,6 +363,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case 1:
return (
<AssetManagementContent
onBack={() => goToWizardStep(0)}
onNext={(taskId: string) => {
// Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY);
@ -420,6 +430,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onBack={() => setActiveItem('새 프로젝트 만들기')}
/>
);
case '콘텐츠 캘린더':
return <ContentCalendarContent onNavigate={handleNavigate} />;
case '내 정보':
return <MyInfoContent />;
case '새 프로젝트 만들기':
@ -447,7 +459,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || isBrandAnalysis;
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) {
@ -468,7 +480,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
)}
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-y-auto' : 'h-full overflow-hidden'}`}>
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{renderContent()}
</div>
</div>

View File

@ -386,7 +386,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return (
<main className="page-container">
<main className="sound-studio-page">
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
@ -517,6 +517,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)}
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{t('soundStudio.generating')}
</>
) : (
t('soundStudio.generateButton')
)}
</button>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
</div>
{/* Right Column - Lyrics */}
@ -578,41 +613,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div>
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{t('soundStudio.generating')}
</>
) : (
t('soundStudio.generateButton')
)}
</button>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
</div>
{/* Bottom Button */}
@ -653,3 +653,4 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
};
export default SoundStudioContent;

View File

@ -366,6 +366,30 @@ export interface SocialUploadStatusResponse {
uploaded_at?: string;
}
// 업로드 히스토리 아이템
export interface UploadHistoryItem {
upload_id: number;
video_id: number;
platform: string;
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
title: string;
channel_name: string;
scheduled_at: string | null;
uploaded_at: string | null;
created_at: string;
platform_url: string | null;
error_message: string | null;
}
// 업로드 히스토리 응답
export interface UploadHistoryResponse {
success: boolean;
items: UploadHistoryItem[];
total?: number;
page?: number;
size?: number;
}
// Social OAuth 토큰 만료 에러 응답
export interface TokenExpiredErrorResponse {
detail: string;

View File

@ -877,3 +877,42 @@ export async function waitForUploadComplete(
return poll();
}
// 업로드 히스토리 조회
export async function getUploadHistory(
tab: 'all' | 'completed' | 'scheduled' | 'failed' = 'all',
options?: { year?: number; month?: number; platform?: string; page?: number; size?: number }
): Promise<import('../types/api').UploadHistoryResponse> {
const params = new URLSearchParams({ tab });
if (options?.year) params.set('year', String(options.year));
if (options?.month) params.set('month', String(options.month));
if (options?.platform) params.set('platform', options.platform);
if (options?.page) params.set('page', String(options.page));
if (options?.size) params.set('size', String(options.size));
const response = await authenticatedFetch(
`${API_URL}/social/upload/history?${params.toString()}`,
{ method: 'GET' }
);
if (!response.ok) throw new Error('히스토리 조회 실패');
return response.json();
}
// 예약 업로드 취소
export async function cancelUpload(uploadId: number): Promise<{ success: boolean; message: string }> {
const response = await authenticatedFetch(
`${API_URL}/social/upload/${uploadId}`,
{ method: 'DELETE' }
);
if (!response.ok) throw new Error('취소 실패');
return response.json();
}
// 업로드 재시도
export async function retryUpload(uploadId: number): Promise<{ success: boolean; message: string }> {
const response = await authenticatedFetch(
`${API_URL}/social/upload/${uploadId}/retry`,
{ method: 'POST' }
);
if (!response.ok) throw new Error('재시도 실패');
return response.json();
}