803 lines
31 KiB
TypeScript
803 lines
31 KiB
TypeScript
|
|
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}
|
|
className="calendar-panel-scroll"
|
|
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
|
|
>
|
|
{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: '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 className="calendar-grid-area" 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 className="calendar-side-panel" 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;
|