diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 392ab39..c742f23 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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" ] } } diff --git a/index.css b/index.css index 67688b3..2889292 100644 --- a/index.css +++ b/index.css @@ -9643,6 +9643,152 @@ color: rgba(255, 255, 255, 0.4); } +/* ===================================================== + Schedule DateTime Picker + ===================================================== */ + +.schedule-datetime-picker { + margin-top: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.mini-calendar { + width: 100%; +} + +.mini-calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.mini-calendar-title { + font-size: 0.85rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.mini-calendar-nav { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 1.2rem; + cursor: pointer; + padding: 2px 8px; + border-radius: 6px; + line-height: 1; + transition: background 0.15s, color 0.15s; +} + +.mini-calendar-nav:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +.mini-calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.mini-calendar-day-label { + text-align: center; + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.4); + padding: 4px 0; +} + +.mini-calendar-cell { + background: none; + border: none; + color: rgba(255, 255, 255, 0.85); + font-size: 0.78rem; + cursor: pointer; + padding: 5px 2px; + border-radius: 6px; + text-align: center; + transition: background 0.15s; +} + +.mini-calendar-cell:hover:not(.disabled):not(.selected) { + background: rgba(255, 255, 255, 0.1); +} + +.mini-calendar-cell.other-month { + color: rgba(255, 255, 255, 0.25); +} + +.mini-calendar-cell.today { + color: #a6ffea; + font-weight: 600; +} + +.mini-calendar-cell.selected { + background: #a65eff; + color: #fff; + font-weight: 600; +} + +.mini-calendar-cell.disabled { + color: rgba(255, 255, 255, 0.2); + cursor: not-allowed; +} + +.schedule-time-picker { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.schedule-time-label { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; +} + +.schedule-time-selects { + display: flex; + gap: 8px; +} + +.schedule-time-select { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.9); + font-size: 0.85rem; + padding: 8px 10px; + cursor: pointer; + outline: none; + appearance: auto; +} + +.schedule-time-select:focus { + border-color: rgba(166, 95, 255, 0.6); +} + +.schedule-time-select option { + background: #1a2a2a; + color: rgba(255, 255, 255, 0.9); +} + +.schedule-datetime-preview { + font-size: 0.8rem; + color: #a65eff; + font-weight: 600; + margin: 0; +} + /* Footer */ .social-posting-footer { display: flex; diff --git a/src/App.tsx b/src/App.tsx index 50374f4..ff628aa 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 686e6a2..acbd509 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -83,6 +83,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: }, { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: }, { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: }, + { id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: }, { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: }, ]; diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index 0d2c499..6b9fa02 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -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 ( +
+
+ + {viewYear}년 {viewMonth + 1}월 + +
+
+ {DAY_LABELS.map(d => ( +
{d}
+ ))} + {cells.map((cell, i) => { + const disabled = isDisabled(cell.date); + const isSelected = selectedDate ? isSameDay(cell.date, selectedDate) : false; + const isToday = isSameDay(cell.date, today); + return ( + + ); + })} +
+
+ ); +}; + const SocialPostingModal: React.FC = ({ isOpen, onClose, @@ -39,6 +123,9 @@ const SocialPostingModal: React.FC = ({ const [tags, setTags] = useState(''); const [privacy, setPrivacy] = useState('public'); const [publishTime, setPublishTime] = useState('now'); + const [scheduledDate, setScheduledDate] = useState(null); + const [scheduledHour, setScheduledHour] = useState(12); + const [scheduledMinute, setScheduledMinute] = useState(0); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [isPosting, setIsPosting] = useState(false); const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false); @@ -137,6 +224,20 @@ const SocialPostingModal: React.FC = ({ 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 +271,15 @@ const SocialPostingModal: React.FC = ({ .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 +287,7 @@ const SocialPostingModal: React.FC = ({ 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 +298,31 @@ const SocialPostingModal: React.FC = ({ 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 +344,9 @@ const SocialPostingModal: React.FC = ({ setTags(''); setPrivacy('public'); setPublishTime('now'); + setScheduledDate(null); + setScheduledHour(12); + setScheduledMinute(0); setSelectedChannel(''); setIsChannelDropdownOpen(false); setIsPrivacyDropdownOpen(false); @@ -267,6 +388,7 @@ const SocialPostingModal: React.FC = ({ channelName={uploadChannelName || selectedAccount?.display_name || ''} youtubeUrl={uploadYoutubeUrl} errorMessage={uploadErrorMessage} + isScheduled={publishTime === 'schedule'} /> ); @@ -491,11 +613,105 @@ const SocialPostingModal: React.FC = ({ value="schedule" checked={publishTime === 'schedule'} onChange={() => setPublishTime('schedule')} - disabled /> - {t('social.publishSchedule')} + {t('social.publishSchedule')} + + {/* 시간 설정 UI */} + {publishTime === 'schedule' && (() => { + const now = new Date(); + const isTodaySelected = scheduledDate + ? scheduledDate.getFullYear() === now.getFullYear() && + scheduledDate.getMonth() === now.getMonth() && + scheduledDate.getDate() === now.getDate() + : false; + + // 오늘 선택 시: 현재 시각 기준으로 선택 가능한 최소 시간 계산 + // 현재 분을 10분 단위 올림하여 최소 분 결정 + const nowMinute = now.getMinutes(); + const minMinuteForCurrentHour = Math.ceil((nowMinute + 1) / 10) * 10; + const minHour = isTodaySelected + ? (minMinuteForCurrentHour >= 60 ? now.getHours() + 1 : now.getHours()) + : 0; + + // 선택된 시간이 오늘 최소 시간보다 작으면 분 옵션 제한 + const minMinuteForSelectedHour = isTodaySelected && scheduledHour === now.getHours() + ? minMinuteForCurrentHour >= 60 ? 0 : minMinuteForCurrentHour + : 0; + + const handleDateSelect = (date: Date) => { + const selectedIsToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + + setScheduledDate(date); + + // 오늘을 선택했을 때 현재 설정된 시간이 과거라면 최솟값으로 보정 + if (selectedIsToday) { + const minMin = Math.ceil((now.getMinutes() + 1) / 10) * 10; + const minH = minMin >= 60 ? now.getHours() + 1 : now.getHours(); + const adjMin = minMin >= 60 ? 0 : minMin; + if (scheduledHour < minH || (scheduledHour === minH && scheduledMinute < adjMin)) { + setScheduledHour(minH); + setScheduledMinute(adjMin); + } + } + }; + + const handleHourChange = (h: number) => { + setScheduledHour(h); + // 시간 변경 시 분이 과거가 되면 최솟값으로 보정 + if (isTodaySelected && h === now.getHours()) { + const minMin = Math.ceil((now.getMinutes() + 1) / 10) * 10; + const adjMin = minMin >= 60 ? 0 : minMin; + if (scheduledMinute < adjMin) setScheduledMinute(adjMin); + } + }; + + return ( +
+ +
+ 시간 설정 +
+ + +
+ {scheduledDate && ( +

+ {scheduledDate.getFullYear()}.{String(scheduledDate.getMonth()+1).padStart(2,'0')}.{String(scheduledDate.getDate()).padStart(2,'0')} {String(scheduledHour).padStart(2,'0')}:{String(scheduledMinute).padStart(2,'0')} 예약 +

+ )} +
+
+ ); + })()} @@ -516,7 +732,12 @@ const SocialPostingModal: React.FC = ({ diff --git a/src/components/UploadProgressModal.tsx b/src/components/UploadProgressModal.tsx index d16403e..b598478 100644 --- a/src/components/UploadProgressModal.tsx +++ b/src/components/UploadProgressModal.tsx @@ -13,6 +13,7 @@ interface UploadProgressModalProps { channelName: string; youtubeUrl?: string; errorMessage?: string; + isScheduled?: boolean; } const UploadProgressModal: React.FC = ({ @@ -24,6 +25,7 @@ const UploadProgressModal: React.FC = ({ channelName, youtubeUrl, errorMessage, + isScheduled = false, }) => { const { t } = useTranslation(); if (!isOpen) return null; @@ -35,7 +37,7 @@ const UploadProgressModal: React.FC = ({ 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 = ({
{/* Header */}
-

{t('upload.title')}

+

{isScheduled && status === 'completed' ? t('upload.titleScheduled') : t('upload.title')}

{canClose && ( - + + ))} +
+ ); + + // ── 통계 바 ──────────────────────────────────────────────── + const renderStats = () => ( +
+ {[ + { label: '예약', value: totalStats.scheduled }, + { label: '완료', value: totalStats.completed }, + { label: '실패', value: totalStats.failed }, + ].map((item, idx) => ( + + {idx > 0 &&
} +
+ + {item.label} + + + {item.value} + +
+ + ))} +
+ ); + + // ── 월 네비게이션 ──────────────────────────────────────────── + const renderMonthNav = () => ( +
+ +

+ {year}년 {monthNames[month]} +

+ +
+ ); + + // ── 업로드 아이템 카드 ──────────────────────────────────────── + const renderItem = (item: UploadHistoryItem) => { + const isScheduled = item.status !== 'completed' && item.status !== 'failed'; + const isFailed = item.status === 'failed'; + return ( +
+ {/* 상단: 상태 + 시간 */} +
+
+ + {statusLabel(item.status as UploadStatus)} + + + {formatTime(item)} + +
+ + {/* 채널 아이콘 + 제목 */} +
+ + + {item.title} + +
+ + {/* 실패 메시지 */} + {isFailed && item.error_message && ( +

+ {item.error_message} +

+ )} + + {/* 액션 버튼 */} + {(isScheduled || isFailed) && ( +
+ {isScheduled && ( + + )} + {isFailed && ( + + )} +
+ )} +
+ ); + }; + + // ── 패널 콘텐츠 ──────────────────────────────────────────── + const renderPanelContent = () => { + if (panelLoading) { + return ( +
+ 불러오는 중... +
+ ); + } + if (sortedDateKeys.length === 0) { + return ( +
+
+

+ 최근 결과 없음 +

+

+ 제작한 콘텐츠를 소셜 채널에 업로드해 보세요 +

+
+ +
+ ); + } + return ( +
+ {sortedDateKeys.map(dateKey => ( +
{ dateRefs.current[dateKey] = el; }} + > +

+ {formatDateLabel(dateKey)} +

+
+ {groupedByDate[dateKey].map(item => renderItem(item))} +
+
+ ))} +
+ ); + }; + + // ════════════════════════════════════════════════════════════════ + // 모바일 레이아웃 + // ════════════════════════════════════════════════════════════════ + if (isMobile) { + return ( +
+
+

+ 콘텐츠 캘린더 +

+
+ +
+ {renderMonthNav()} + {renderStats()} +
+ +
+ {renderCalendarGrid()} +
+ + {/* 바텀시트 */} +
+
+
setSheetOpen(o => !o)} + style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }} + > +
+
+
+ {renderTabs()} +
+ {sheetOpen && ( +
+ {renderPanelContent()} +
+ )} +
+
+
+
+ ); + } + + // ════════════════════════════════════════════════════════════════ + // 데스크탑 레이아웃 + // ════════════════════════════════════════════════════════════════ + return ( +
+
+

+ 콘텐츠 캘린더 +

+
+ +
+ {/* 캘린더 영역 */} +
+
+ {renderMonthNav()} + {renderStats()} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : renderCalendarGrid()} +
+ + {/* 오른쪽 패널 */} +
+
+ {renderTabs()} +
+ {renderPanelContent()} +
+
+
+ ); +}; + +export default ContentCalendarContent; diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index dcecd3e..be2ae3b 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -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 = ({ // 초기 위저드 단계 결정 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 = ({ // 기존 프로젝트 데이터 초기화 clearAllProjectStorage(); localStorage.removeItem(ANALYSIS_DATA_KEY); + // URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원) + localStorage.setItem(WIZARD_STEP_KEY, '-2'); setWizardStep(-2); setSongTaskId(null); setImageTaskId(null); @@ -420,6 +429,8 @@ const GenerationFlow: React.FC = ({ onBack={() => setActiveItem('새 프로젝트 만들기')} /> ); + case '콘텐츠 캘린더': + return ; case '내 정보': return ; case '새 프로젝트 만들기': @@ -447,7 +458,7 @@ const GenerationFlow: React.FC = ({ // 브랜드 분석(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 +479,7 @@ const GenerationFlow: React.FC = ({ {showSidebar && ( )} -
+
{renderContent()}
diff --git a/src/types/api.ts b/src/types/api.ts index 8febfcd..1440eb8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; diff --git a/src/utils/api.ts b/src/utils/api.ts index cf85190..481509e 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -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 { + 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(); +}