Merge branch 'main' of https://gitea.o2o.kr/castad/o2o-castad-frontend
commit
cd4114f74d
|
|
@ -10,7 +10,8 @@
|
||||||
"mcp__figma__get_figma_data",
|
"mcp__figma__get_figma_data",
|
||||||
"mcp__figma__download_figma_images",
|
"mcp__figma__download_figma_images",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(grep:*)"
|
"Bash(grep:*)",
|
||||||
|
"mcp__claude_ai_Figma__get_design_context"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
index.css
146
index.css
|
|
@ -9694,6 +9694,152 @@
|
||||||
color: rgba(255, 255, 255, 0.4);
|
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 */
|
/* Footer */
|
||||||
.social-posting-footer {
|
.social-posting-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
22
src/App.tsx
22
src/App.tsx
|
|
@ -135,19 +135,15 @@ const App: React.FC = () => {
|
||||||
url.searchParams.delete('refresh_token');
|
url.searchParams.delete('refresh_token');
|
||||||
window.history.replaceState({}, document.title, url.pathname);
|
window.history.replaceState({}, document.title, url.pathname);
|
||||||
|
|
||||||
// 로그인 성공 - 분석 데이터 유무에 따라 분기
|
|
||||||
// 비로그인 상태에서 URL 입력 후 브랜드 분석을 본 경우 → 바로 generation_flow로 (URL 입력 스킵)
|
|
||||||
// 홈에서 바로 로그인한 경우 → generation_flow로 (URL 입력 필요)
|
|
||||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
if (savedData) {
|
if (savedData) {
|
||||||
// 분석 데이터가 있으면 바로 에셋 관리로
|
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
||||||
setInitialTab('새 프로젝트 만들기');
|
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
||||||
setViewMode('generation_flow');
|
localStorage.removeItem('castad_wizard_step');
|
||||||
} else {
|
localStorage.removeItem('castad_active_item');
|
||||||
// 분석 데이터가 없으면 URL 입력부터 시작하도록 generation_flow로
|
|
||||||
setInitialTab('새 프로젝트 만들기');
|
|
||||||
setViewMode('generation_flow');
|
|
||||||
}
|
}
|
||||||
|
setInitialTab('새 프로젝트 만들기');
|
||||||
|
setViewMode('generation_flow');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Token callback failed:', err);
|
console.error('Token callback failed:', err);
|
||||||
alert(t('app.loginFailed'));
|
alert(t('app.loginFailed'));
|
||||||
|
|
@ -335,6 +331,12 @@ const App: React.FC = () => {
|
||||||
const handleToLogin = async () => {
|
const handleToLogin = async () => {
|
||||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
|
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
|
||||||
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
|
if (savedData) {
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
|
}
|
||||||
setInitialTab('새 프로젝트 만들기');
|
setInitialTab('새 프로젝트 만들기');
|
||||||
setViewMode('generation_flow');
|
setViewMode('generation_flow');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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: '새 프로젝트 만들기', 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: '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: 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> },
|
{ 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> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> = ({
|
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -39,6 +123,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [tags, setTags] = useState('');
|
const [tags, setTags] = useState('');
|
||||||
const [privacy, setPrivacy] = useState<PrivacyType>('public');
|
const [privacy, setPrivacy] = useState<PrivacyType>('public');
|
||||||
const [publishTime, setPublishTime] = useState<PublishTimeType>('now');
|
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 [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false);
|
const [isChannelDropdownOpen, setIsChannelDropdownOpen] = useState(false);
|
||||||
|
|
@ -148,6 +235,20 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
return;
|
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);
|
const selectedAcc = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||||
if (!selectedAcc) {
|
if (!selectedAcc) {
|
||||||
alert(t('social.selectChannel'));
|
alert(t('social.selectChannel'));
|
||||||
|
|
@ -181,6 +282,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
.filter(tag => tag.length > 0);
|
.filter(tag => tag.length > 0);
|
||||||
|
|
||||||
// Request payload 로그
|
// 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 = {
|
const requestPayload = {
|
||||||
video_id: video.video_id,
|
video_id: video.video_id,
|
||||||
social_account_id: selectedAcc.id,
|
social_account_id: selectedAcc.id,
|
||||||
|
|
@ -188,7 +298,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
tags: tagsArray,
|
tags: tagsArray,
|
||||||
privacy_status: privacy,
|
privacy_status: privacy,
|
||||||
scheduled_at: publishTime === 'now' ? null : null,
|
scheduled_at: scheduledAt,
|
||||||
};
|
};
|
||||||
console.log('[Upload] Request payload:', requestPayload);
|
console.log('[Upload] Request payload:', requestPayload);
|
||||||
|
|
||||||
|
|
@ -199,23 +309,31 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for upload completion
|
if (publishTime === 'schedule') {
|
||||||
const result = await waitForUploadComplete(
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
uploadResponse.upload_id,
|
setUploadStatus('completed');
|
||||||
(status, progress) => {
|
setUploadProgress(100);
|
||||||
setUploadStatus(status as UploadStatus);
|
onClose();
|
||||||
setUploadProgress(progress || 0);
|
resetForm();
|
||||||
}
|
} else {
|
||||||
);
|
// 즉시 업로드: 완료될 때까지 폴링
|
||||||
|
const result = await waitForUploadComplete(
|
||||||
|
uploadResponse.upload_id,
|
||||||
|
(status, progress) => {
|
||||||
|
setUploadStatus(status as UploadStatus);
|
||||||
|
setUploadProgress(progress || 0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Upload completed successfully
|
// Upload completed successfully
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
setUploadYoutubeUrl(result.platform_url);
|
setUploadYoutubeUrl(result.platform_url);
|
||||||
|
|
||||||
// Close the posting modal (keep progress modal open)
|
// Close the posting modal (keep progress modal open)
|
||||||
onClose();
|
onClose();
|
||||||
resetForm();
|
resetForm();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TokenExpiredError) {
|
if (error instanceof TokenExpiredError) {
|
||||||
setShowUploadProgress(false);
|
setShowUploadProgress(false);
|
||||||
|
|
@ -237,6 +355,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
setTags('');
|
setTags('');
|
||||||
setPrivacy('public');
|
setPrivacy('public');
|
||||||
setPublishTime('now');
|
setPublishTime('now');
|
||||||
|
setScheduledDate(null);
|
||||||
|
setScheduledHour(12);
|
||||||
|
setScheduledMinute(0);
|
||||||
setSelectedChannel('');
|
setSelectedChannel('');
|
||||||
setIsChannelDropdownOpen(false);
|
setIsChannelDropdownOpen(false);
|
||||||
setIsPrivacyDropdownOpen(false);
|
setIsPrivacyDropdownOpen(false);
|
||||||
|
|
@ -278,6 +399,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
||||||
youtubeUrl={uploadYoutubeUrl}
|
youtubeUrl={uploadYoutubeUrl}
|
||||||
errorMessage={uploadErrorMessage}
|
errorMessage={uploadErrorMessage}
|
||||||
|
isScheduled={publishTime === 'schedule'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -506,11 +628,105 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
value="schedule"
|
value="schedule"
|
||||||
checked={publishTime === 'schedule'}
|
checked={publishTime === 'schedule'}
|
||||||
onChange={() => setPublishTime('schedule')}
|
onChange={() => setPublishTime('schedule')}
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
<span className="radio-label disabled">{t('social.publishSchedule')}</span>
|
<span className="radio-label">{t('social.publishSchedule')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -531,7 +747,12 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
<button
|
<button
|
||||||
className="social-posting-btn submit"
|
className="social-posting-btn submit"
|
||||||
onClick={handlePost}
|
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')}
|
{isPosting ? t('social.posting') : t('social.post')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface UploadProgressModalProps {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
isScheduled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
|
|
@ -24,6 +25,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
channelName,
|
channelName,
|
||||||
youtubeUrl,
|
youtubeUrl,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
isScheduled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
@ -35,7 +37,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
case 'uploading':
|
case 'uploading':
|
||||||
return t('upload.statusUploading');
|
return t('upload.statusUploading');
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return t('upload.statusCompleted');
|
return isScheduled ? t('upload.statusScheduled') : t('upload.statusCompleted');
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return t('upload.statusFailed');
|
return t('upload.statusFailed');
|
||||||
default:
|
default:
|
||||||
|
|
@ -83,7 +85,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
<div className="upload-progress-modal">
|
<div className="upload-progress-modal">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="upload-progress-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 && (
|
{canClose && (
|
||||||
<button className="upload-progress-close" onClick={onClose}>
|
<button className="upload-progress-close" onClick={onClose}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,11 @@
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube Upload",
|
"title": "YouTube Upload",
|
||||||
|
"titleScheduled": "YouTube Schedule",
|
||||||
"statusPending": "Preparing upload...",
|
"statusPending": "Preparing upload...",
|
||||||
"statusUploading": "Uploading...",
|
"statusUploading": "Uploading...",
|
||||||
"statusCompleted": "Upload complete!",
|
"statusCompleted": "Upload complete!",
|
||||||
|
"statusScheduled": "Scheduled!",
|
||||||
"statusFailed": "Upload failed",
|
"statusFailed": "Upload failed",
|
||||||
"statusDefault": "Processing...",
|
"statusDefault": "Processing...",
|
||||||
"videoTitleLabel": "Video Title",
|
"videoTitleLabel": "Video Title",
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,11 @@
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube 업로드",
|
"title": "YouTube 업로드",
|
||||||
|
"titleScheduled": "YouTube 예약 등록",
|
||||||
"statusPending": "업로드 준비 중...",
|
"statusPending": "업로드 준비 중...",
|
||||||
"statusUploading": "업로드 중...",
|
"statusUploading": "업로드 중...",
|
||||||
"statusCompleted": "업로드 완료!",
|
"statusCompleted": "업로드 완료!",
|
||||||
|
"statusScheduled": "예약 완료!",
|
||||||
"statusFailed": "업로드 실패",
|
"statusFailed": "업로드 실패",
|
||||||
"statusDefault": "처리 중...",
|
"statusDefault": "처리 중...",
|
||||||
"videoTitleLabel": "영상 제목",
|
"videoTitleLabel": "영상 제목",
|
||||||
|
|
|
||||||
|
|
@ -207,25 +207,25 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
<div className="content-card-actions">
|
<div className="content-card-actions">
|
||||||
<button
|
<button
|
||||||
className="content-download-btn"
|
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)}
|
onClick={() => handleUploadClick(video)}
|
||||||
disabled={!video.result_movie_url}
|
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">
|
<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="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||||
<path d="M3 15v2h14v-2"/>
|
<path d="M3 15v2h14v-2"/>
|
||||||
</svg>
|
</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>
|
||||||
<button
|
<button
|
||||||
className="content-delete-btn"
|
className="content-delete-btn"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -10,6 +10,7 @@ import BusinessSettingsContent from './BusinessSettingsContent';
|
||||||
import UrlInputContent from './UrlInputContent';
|
import UrlInputContent from './UrlInputContent';
|
||||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||||
import MyInfoContent from './MyInfoContent';
|
import MyInfoContent from './MyInfoContent';
|
||||||
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
|
|
@ -88,11 +89,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
|
|
||||||
// 초기 위저드 단계 결정
|
// 초기 위저드 단계 결정
|
||||||
const getInitialWizardStep = (): number => {
|
const getInitialWizardStep = (): number => {
|
||||||
// 저장된 단계가 있으면 사용
|
|
||||||
if (savedWizardStep !== null) {
|
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;
|
const hasAnalysisData = initialAnalysisData || savedAnalysisData;
|
||||||
return hasAnalysisData ? 1 : -2;
|
return hasAnalysisData ? 1 : -2;
|
||||||
};
|
};
|
||||||
|
|
@ -310,6 +317,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 기존 프로젝트 데이터 초기화
|
// 기존 프로젝트 데이터 초기화
|
||||||
clearAllProjectStorage();
|
clearAllProjectStorage();
|
||||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
|
// URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원)
|
||||||
|
localStorage.setItem(WIZARD_STEP_KEY, '-2');
|
||||||
setWizardStep(-2);
|
setWizardStep(-2);
|
||||||
setSongTaskId(null);
|
setSongTaskId(null);
|
||||||
setImageTaskId(null);
|
setImageTaskId(null);
|
||||||
|
|
@ -420,6 +429,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case '콘텐츠 캘린더':
|
||||||
|
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent />;
|
return <MyInfoContent />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
|
|
@ -447,7 +458,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || isBrandAnalysis;
|
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||||
|
|
||||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||||
if (isBrandAnalysis) {
|
if (isBrandAnalysis) {
|
||||||
|
|
@ -468,7 +479,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<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()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,30 @@ export interface SocialUploadStatusResponse {
|
||||||
uploaded_at?: string;
|
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 토큰 만료 에러 응답
|
// Social OAuth 토큰 만료 에러 응답
|
||||||
export interface TokenExpiredErrorResponse {
|
export interface TokenExpiredErrorResponse {
|
||||||
detail: string;
|
detail: string;
|
||||||
|
|
|
||||||
|
|
@ -877,3 +877,42 @@ export async function waitForUploadComplete(
|
||||||
|
|
||||||
return poll();
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue