894 lines
38 KiB
TypeScript
Executable File
894 lines
38 KiB
TypeScript
Executable File
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { authenticatedFetch, API_URL } from '../../utils/api';
|
|
import {
|
|
ResponsiveContainer, ComposedChart, Area, Line,
|
|
XAxis, CartesianGrid, Tooltip,
|
|
} from 'recharts';
|
|
|
|
// =====================================================
|
|
// Types
|
|
// =====================================================
|
|
|
|
interface ContentMetric {
|
|
id: string;
|
|
label: string;
|
|
value: number; // 원시 숫자값 (단위: unit 참조)
|
|
unit: string; // "count" | "hours" | "minutes"
|
|
trend: number;
|
|
trendDirection: 'up' | 'down' | '-';
|
|
}
|
|
|
|
interface MonthlyData {
|
|
month: string;
|
|
thisYear: number;
|
|
lastYear: number;
|
|
}
|
|
|
|
interface DailyData {
|
|
date: string;
|
|
thisPeriod: number;
|
|
lastPeriod: number;
|
|
}
|
|
|
|
// interface PlatformMetric { // 미사용 — platform_data 기능 예정
|
|
// id: string;
|
|
// label: string;
|
|
// value: string;
|
|
// unit?: string;
|
|
// trend: number;
|
|
// trendDirection: 'up' | 'down';
|
|
// }
|
|
|
|
// interface PlatformData { // 미사용 — platform_data 기능 예정
|
|
// platform: 'youtube' | 'instagram';
|
|
// displayName: string;
|
|
// metrics: PlatformMetric[];
|
|
// }
|
|
|
|
interface TopContent {
|
|
id: string;
|
|
title: string;
|
|
thumbnail: string;
|
|
platform: 'youtube' | 'instagram';
|
|
views: number; // 원시 정수 (포맷팅은 formatNumber로 처리)
|
|
engagement: string;
|
|
date: string;
|
|
}
|
|
|
|
interface AudienceData {
|
|
ageGroups: { label: string; percentage: number }[];
|
|
gender: { male: number; female: number };
|
|
topRegions: { region: string; percentage: number }[];
|
|
}
|
|
|
|
interface DashboardResponse {
|
|
contentMetrics: ContentMetric[];
|
|
monthlyData: MonthlyData[];
|
|
dailyData: DailyData[];
|
|
topContent: TopContent[];
|
|
audienceData: AudienceData;
|
|
hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시)
|
|
// platformData: PlatformData[]; // 미사용
|
|
}
|
|
|
|
interface ConnectedAccount {
|
|
id: number;
|
|
platform: string;
|
|
platformUserId: string;
|
|
platformUsername?: string | null;
|
|
channelTitle?: string | null;
|
|
connectedAt: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
// =====================================================
|
|
// Mock Data (연동 전 샘플 데이터)
|
|
// =====================================================
|
|
|
|
const MOCK_CONTENT_METRICS: ContentMetric[] = [
|
|
{ id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
|
|
{ id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
|
|
{ id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
|
|
{ id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
|
|
{ id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
|
|
{ id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
|
|
{ id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
|
|
{ id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
|
|
];
|
|
|
|
const MOCK_MONTHLY_DATA: MonthlyData[] = [
|
|
{ month: '1월', thisYear: 18000, lastYear: 14500 },
|
|
{ month: '2월', thisYear: 19500, lastYear: 15800 },
|
|
{ month: '3월', thisYear: 21000, lastYear: 17200 },
|
|
{ month: '4월', thisYear: 18500, lastYear: 16800 },
|
|
{ month: '5월', thisYear: 24000, lastYear: 19500 },
|
|
{ month: '6월', thisYear: 27500, lastYear: 21000 },
|
|
{ month: '7월', thisYear: 32000, lastYear: 23500 },
|
|
{ month: '8월', thisYear: 29500, lastYear: 24800 },
|
|
{ month: '9월', thisYear: 31000, lastYear: 26200 },
|
|
{ month: '10월', thisYear: 28500, lastYear: 25500 },
|
|
{ month: '11월', thisYear: 34000, lastYear: 27800 },
|
|
{ month: '12월', thisYear: 38000, lastYear: 29500 },
|
|
];
|
|
|
|
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
|
|
const month = i <= 11 ? 1 : 2;
|
|
const day = i <= 11 ? i + 20 : i - 11;
|
|
const thisPeriod = [820, 910, 780, 1020, 890, 1100, 950, 870, 1010, 980, 1120, 1050, 930, 860, 1080, 1150, 970, 1030, 800, 990, 1180, 1070, 920, 880, 1040, 1110, 960, 1000, 850, 940][i];
|
|
const lastPeriod = [680, 720, 650, 800, 700, 800, 770, 710, 800, 790, 900, 860, 760, 700, 870, 920, 790, 840, 690, 800, 940, 870, 700, 720, 850, 890, 780, 810, 690, 760][i];
|
|
return { date: `${month}/${day}`, thisPeriod, lastPeriod };
|
|
});
|
|
|
|
const MOCK_TOP_CONTENT: TopContent[] = [
|
|
{ id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: 125400, engagement: '8.2%', date: '2025.01.15' },
|
|
{ id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: 89200, engagement: '12.5%', date: '2025.01.22' },
|
|
{ id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: 67800, engagement: '6.4%', date: '2025.01.08' },
|
|
{ id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: 54300, engagement: '15.8%', date: '2025.01.28' },
|
|
];
|
|
|
|
const MOCK_AUDIENCE_DATA: AudienceData = {
|
|
ageGroups: [
|
|
{ label: '18-24', percentage: 12 },
|
|
{ label: '25-34', percentage: 35 },
|
|
{ label: '35-44', percentage: 28 },
|
|
{ label: '45-54', percentage: 18 },
|
|
{ label: '55+', percentage: 7 },
|
|
],
|
|
gender: { male: 42, female: 58 },
|
|
topRegions: [
|
|
{ region: '서울', percentage: 32 },
|
|
{ region: '경기', percentage: 24 },
|
|
{ region: '부산', percentage: 12 },
|
|
{ region: '인천', percentage: 8 },
|
|
{ region: '대구', percentage: 6 },
|
|
],
|
|
};
|
|
|
|
// =====================================================
|
|
// Animation Components
|
|
// =====================================================
|
|
|
|
interface AnimatedItemProps {
|
|
children: React.ReactNode;
|
|
index: number;
|
|
baseDelay?: number;
|
|
className?: string;
|
|
}
|
|
|
|
const AnimatedItem: React.FC<AnimatedItemProps> = ({ children, index, baseDelay = 0, className = '' }) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const delay = baseDelay + index * 80;
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [delay]);
|
|
|
|
return (
|
|
<div
|
|
className={`transition-all duration-500 ${
|
|
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4 pointer-events-none'
|
|
} ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface AnimatedSectionProps {
|
|
children: React.ReactNode;
|
|
delay?: number;
|
|
className?: string;
|
|
}
|
|
|
|
const AnimatedSection: React.FC<AnimatedSectionProps> = ({ children, delay = 0, className = '' }) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [delay]);
|
|
|
|
return (
|
|
<div
|
|
className={`transition-all duration-600 ${
|
|
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6 pointer-events-none'
|
|
} ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formatNumber = (num: number): string => {
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
return num.toString();
|
|
};
|
|
|
|
// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리)
|
|
const formatValue = (value: number, unit: string): string => {
|
|
if (unit === 'hours') return value.toFixed(1) + '시간';
|
|
if (unit === 'minutes') return Math.round(value) + '분';
|
|
return formatNumber(Math.round(value));
|
|
};
|
|
|
|
// unit에 따라 trend 절댓값을 표시용 문자열로 변환
|
|
const formatTrend = (trend: number, unit: string): string => {
|
|
const abs = Math.abs(trend);
|
|
if (unit === 'hours') return abs.toFixed(1) + '시간';
|
|
if (unit === 'minutes') return Math.round(abs) + '분';
|
|
return formatNumber(Math.round(abs));
|
|
};
|
|
|
|
// =====================================================
|
|
// UI Components
|
|
// =====================================================
|
|
|
|
const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
|
|
<svg
|
|
className="stat-trend-icon"
|
|
viewBox="0 0 12 12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
{direction === 'up' ? (
|
|
<path d="M6 9V3M3 5l3-3 3 3" />
|
|
) : (
|
|
<path d="M6 3v6M3 7l3 3 3-3" />
|
|
)}
|
|
</svg>
|
|
);
|
|
|
|
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
|
|
const isNeutral = trend === 0 || trendDirection === '-';
|
|
const isUp = trendDirection === 'up';
|
|
return (
|
|
<div className="stat-card">
|
|
<span className="stat-label">{label}</span>
|
|
<h3 className="stat-value">{formatValue(value, unit)}</h3>
|
|
<div className="stat-trend-wrapper">
|
|
{isNeutral ? (
|
|
<span className="stat-trend neutral">─</span>
|
|
) : (
|
|
<span className={`stat-trend ${isUp ? 'up' : 'down'}`}>
|
|
<TrendIcon direction={isUp ? 'up' : 'down'} />
|
|
{isUp ? '+' : '-'}{formatTrend(trend, unit)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =====================================================
|
|
// Chart Component with Tooltip
|
|
// =====================================================
|
|
|
|
// 차트에 전달하는 통합 데이터 포인트 타입
|
|
// MonthlyData / DailyData 모두 이 형식으로 변환하여 사용
|
|
interface ChartDataPoint {
|
|
label: string; // X축 레이블 (월별: "1월", 일별: "1/20")
|
|
current: number; // 현재 기간 (thisYear 또는 thisPeriod)
|
|
previous: number; // 이전 기간 (lastYear 또는 lastPeriod)
|
|
}
|
|
|
|
const YearOverYearChart: React.FC<{
|
|
data: ChartDataPoint[];
|
|
currentLabel: string;
|
|
previousLabel: string;
|
|
mode: 'day' | 'month';
|
|
}> = ({ data, currentLabel, previousLabel, mode }) => {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
|
|
<span>이 기간에 데이터가 없습니다.</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) => {
|
|
if (!active || !payload?.length) return null;
|
|
const current = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'current')?.value ?? 0;
|
|
const previous = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'previous')?.value ?? 0;
|
|
const isEqual = current === previous;
|
|
const isUp = current > previous;
|
|
const diff = Math.abs(current - previous);
|
|
const changeClass = isEqual ? ' neutral' : isUp ? '' : ' down';
|
|
return (
|
|
<div className="chart-tooltip">
|
|
<div className="chart-tooltip-title">{label}</div>
|
|
<div className="chart-tooltip-row">
|
|
<span className="chart-tooltip-dot mint"></span>
|
|
<span className="chart-tooltip-label">{currentLabel}</span>
|
|
<span className="chart-tooltip-value">{formatNumber(current)}</span>
|
|
</div>
|
|
<div className="chart-tooltip-row">
|
|
<span className="chart-tooltip-dot purple"></span>
|
|
<span className="chart-tooltip-label">{previousLabel}</span>
|
|
<span className="chart-tooltip-value">{formatNumber(previous)}</span>
|
|
</div>
|
|
<div className={`chart-tooltip-change${changeClass}`}>
|
|
{isEqual ? '─' : `${isUp ? '↑' : '↓'} ${formatNumber(diff)}`}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="yoy-chart-wrapper">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={data} margin={{ top: 20, right: 20, bottom: 0, left: 20 }}>
|
|
<defs>
|
|
<linearGradient id="thisYearGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.25" />
|
|
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid stroke="white" strokeOpacity={0.05} vertical={false} />
|
|
<XAxis
|
|
dataKey="label"
|
|
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 12 }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
interval={mode === 'day' ? 1 : 0}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="current"
|
|
stroke="#a6ffea"
|
|
strokeWidth={3}
|
|
fill="url(#thisYearGradient)"
|
|
dot={{ fill: '#a6ffea', r: 4, filter: 'drop-shadow(0 0 6px rgba(166,255,234,0.6))' }}
|
|
activeDot={{ r: 6, fill: '#a6ffea' }}
|
|
isAnimationActive={true}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="previous"
|
|
stroke="#a682ff"
|
|
strokeWidth={2}
|
|
strokeDasharray="6 4"
|
|
dot={false}
|
|
opacity={0.7}
|
|
isAnimationActive={true}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =====================================================
|
|
// Icon Components
|
|
// =====================================================
|
|
|
|
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
</svg>
|
|
);
|
|
|
|
const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
|
|
</svg>
|
|
);
|
|
|
|
// =====================================================
|
|
// Content Components
|
|
// =====================================================
|
|
|
|
const TopContentItem: React.FC<TopContent> = ({ title, thumbnail, platform, views, engagement, date }) => (
|
|
<div className="top-content-item">
|
|
<div className="top-content-thumbnail">
|
|
<img src={thumbnail} alt={title} />
|
|
<div className="top-content-platform-badge">
|
|
{platform === 'youtube' ? <YouTubeIcon className="top-content-platform-icon" /> : <InstagramIcon className="top-content-platform-icon" />}
|
|
</div>
|
|
</div>
|
|
<div className="top-content-info">
|
|
<h4 className="top-content-title">{title}</h4>
|
|
<div className="top-content-stats">
|
|
<span className="top-content-stat">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
{formatNumber(views)}
|
|
</span>
|
|
<span className="top-content-stat">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
</svg>
|
|
{engagement}
|
|
</span>
|
|
</div>
|
|
<span className="top-content-date">{date}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const AudienceBarChart: React.FC<{ data: { label: string; percentage: number }[]; delay?: number }> = ({ data, delay = 0 }) => {
|
|
const [isAnimated, setIsAnimated] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsAnimated(true), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [delay]);
|
|
|
|
return (
|
|
<div className="audience-bar-chart">
|
|
{data.map((item, index) => (
|
|
<div key={item.label} className="audience-bar-item">
|
|
<span className="audience-bar-label">{item.label}</span>
|
|
<div className="audience-bar-track">
|
|
<div
|
|
className="audience-bar-fill"
|
|
style={{
|
|
width: isAnimated ? `${item.percentage}%` : '0%',
|
|
transitionDelay: `${index * 100}ms`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="audience-bar-value">{item.percentage}%</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = ({ male, female, delay = 0 }) => {
|
|
const { t } = useTranslation();
|
|
const [isAnimated, setIsAnimated] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsAnimated(true), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [delay]);
|
|
|
|
return (
|
|
<div className="gender-chart">
|
|
<div className="gender-chart-bars">
|
|
<div className="gender-bar male" style={{ width: isAnimated ? `${male}%` : '0%' }}>
|
|
<span>{male}%</span>
|
|
</div>
|
|
<div className="gender-bar female" style={{ width: isAnimated ? `${female}%` : '0%' }}>
|
|
<span>{female}%</span>
|
|
</div>
|
|
</div>
|
|
<div className="gender-chart-labels">
|
|
<span className="gender-label male">{t('dashboard.male')}</span>
|
|
<span className="gender-label female">{t('dashboard.female')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =====================================================
|
|
// Main Component
|
|
// =====================================================
|
|
|
|
interface DashboardContentProps {
|
|
onNavigate?: (id: string) => void;
|
|
}
|
|
|
|
const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
|
const { t } = useTranslation();
|
|
const [mode, setMode] = useState<'day' | 'month'>('month');
|
|
|
|
const [dashboardData, setDashboardData] = useState<DashboardResponse | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 계정 관련 state
|
|
const [accounts, setAccounts] = useState<ConnectedAccount[]>([]);
|
|
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(() => {
|
|
const stored = localStorage.getItem('castad_selected_account_id');
|
|
return stored ? Number(stored) : null;
|
|
});
|
|
const [accountsLoaded, setAccountsLoaded] = useState(false);
|
|
const [accountDropdownOpen, setAccountDropdownOpen] = useState(false);
|
|
const accountDropdownRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// 드롭다운 외부 클릭 시 닫기
|
|
useEffect(() => {
|
|
if (!accountDropdownOpen) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (accountDropdownRef.current && !accountDropdownRef.current.contains(e.target as Node)) {
|
|
setAccountDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [accountDropdownOpen]);
|
|
|
|
// 연결된 YouTube 계정 목록 fetch (마운트 시 1회)
|
|
useEffect(() => {
|
|
const fetchAccounts = async () => {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_URL}/dashboard/accounts`, { method: 'GET' });
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
// snake_case(Python 백엔드) / camelCase 모두 처리
|
|
const list: ConnectedAccount[] = (data.accounts ?? []).map((acc: Record<string, unknown>) => ({
|
|
id: acc.id as number,
|
|
platform: acc.platform as string,
|
|
platformUserId: (acc.platformUserId ?? acc.platform_user_id ?? null) as string | null,
|
|
platformUsername: (acc.platformUsername ?? acc.platform_username ?? null) as string | null,
|
|
channelTitle: (acc.channelTitle ?? acc.channel_title ?? null) as string | null,
|
|
connectedAt: (acc.connectedAt ?? acc.connected_at ?? '') as string,
|
|
isActive: (acc.isActive ?? acc.is_active ?? true) as boolean,
|
|
}));
|
|
setAccounts(list);
|
|
|
|
// localStorage 값이 현재 계정 목록에 없으면 초기화
|
|
const storedId = localStorage.getItem('castad_selected_account_id');
|
|
if (storedId && !list.some((a: ConnectedAccount) => a.id === Number(storedId))) {
|
|
localStorage.removeItem('castad_selected_account_id');
|
|
setSelectedAccountId(null);
|
|
}
|
|
|
|
if (list.length === 1) {
|
|
setSelectedAccountId(list[0].id);
|
|
localStorage.setItem('castad_selected_account_id', String(list[0].id));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch accounts:', err);
|
|
} finally {
|
|
setAccountsLoaded(true);
|
|
}
|
|
};
|
|
fetchAccounts();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!accountsLoaded) return;
|
|
if (accounts.length > 1 && selectedAccountId === null) {
|
|
setIsLoading(false); // 계정 선택 전 로딩 종료 → 드롭다운 표시
|
|
return;
|
|
}
|
|
|
|
const fetchDashboardData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
const params = new URLSearchParams({ mode });
|
|
// selectedAccountId로 계정을 찾아 platformUserId를 API 파라미터로 전달
|
|
if (selectedAccountId !== null) {
|
|
const selectedAccount = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId);
|
|
if (selectedAccount?.platformUserId) {
|
|
params.set('platform_user_id', selectedAccount.platformUserId);
|
|
}
|
|
}
|
|
|
|
const response = await authenticatedFetch(`${API_URL}/dashboard/stats?${params}`, { method: 'GET' });
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
|
|
if (errorData.code === 'YOUTUBE_NOT_CONNECTED') {
|
|
setError('YouTube 계정을 연동하여 데이터를 확인하세요.');
|
|
setDashboardData(null);
|
|
return;
|
|
}
|
|
if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') {
|
|
setDashboardData(null);
|
|
return;
|
|
}
|
|
throw new Error(errorData.detail || `API Error: ${response.status}`);
|
|
}
|
|
|
|
const data: DashboardResponse = await response.json();
|
|
setDashboardData(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
console.error('Dashboard API Error:', err);
|
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
setDashboardData(null);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchDashboardData();
|
|
}, [mode, selectedAccountId, accountsLoaded]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center h-screen">
|
|
<div className="text-lg">{t('데이터 로딩 중...')}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// hasUploads === false: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
|
|
const isEmptyState = dashboardData?.hasUploads === false;
|
|
|
|
// API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백
|
|
const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
|
|
const topContent = (!isEmptyState && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
|
|
const hasRealAudienceData = !isEmptyState && !!dashboardData?.audienceData?.ageGroups?.length;
|
|
const audienceData = hasRealAudienceData ? dashboardData!.audienceData : MOCK_AUDIENCE_DATA;
|
|
|
|
// mode별 차트 데이터를 ChartDataPoint 통합 형식으로 변환
|
|
const chartData: ChartDataPoint[] = mode === 'month'
|
|
? ((!isEmptyState && dashboardData?.monthlyData?.length)
|
|
? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))
|
|
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })))
|
|
: ((!isEmptyState && dashboardData?.dailyData?.length)
|
|
? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))
|
|
: MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })));
|
|
|
|
// mode별 차트 레이블
|
|
const chartCurrentLabel = mode === 'month' ? '올해' : '이번달';
|
|
const chartPreviousLabel = mode === 'month' ? '작년' : '지난달';
|
|
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장';
|
|
|
|
const lastUpdated = new Date().toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
});
|
|
|
|
return (
|
|
<div className="dashboard-container">
|
|
{/* 업로드 영상 없음 배너 */}
|
|
{isEmptyState && (
|
|
<div className="mb-4 p-4 border-l-4 rounded" style={{ background: 'rgba(166,255,234,0.06)', borderColor: '#a6ffea' }}>
|
|
<div className="flex items-center gap-3">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#a6ffea" strokeWidth="2" style={{ flexShrink: 0 }}>
|
|
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
<div>
|
|
<p style={{ color: '#a6ffea', fontWeight: 600, marginBottom: '2px' }}>아직 업로드된 영상이 없습니다.</p>
|
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다. 현재 샘플 데이터입니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 에러 배너 */}
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800 rounded">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center">
|
|
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
<div>
|
|
<p className="font-semibold">아래는 샘플 데이터입니다. 실제 데이터를 보려면 계정을 연동하세요.</p>
|
|
</div>
|
|
</div>
|
|
{error.includes('YouTube') && (
|
|
<button
|
|
onClick={() => onNavigate?.('내 정보')}
|
|
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
|
>
|
|
연동하러 가기 →
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<AnimatedSection delay={0} className="relative z-10">
|
|
<div className="dashboard-header-row">
|
|
<div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<h1 className="dashboard-title">{t('dashboard.title')}</h1>
|
|
{/* 계정 드롭다운: 연결된 계정이 2개 이상일 때만 표시 */}
|
|
{accounts.length > 1 && (
|
|
<div ref={accountDropdownRef} style={{ position: 'relative', zIndex: 11 }}>
|
|
{/* 트리거 버튼 */}
|
|
<button
|
|
onClick={() => setAccountDropdownOpen(!accountDropdownOpen)}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '6px',
|
|
background: '#001a1c',
|
|
border: '1px solid rgba(255,255,255,0.15)',
|
|
borderRadius: '8px',
|
|
color: 'rgba(255,255,255,0.85)',
|
|
padding: '5px 10px',
|
|
fontSize: '13px',
|
|
cursor: 'pointer',
|
|
minWidth: '160px',
|
|
}}
|
|
>
|
|
{(() => {
|
|
const acc = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId);
|
|
return acc ? (
|
|
<>
|
|
{acc.platform === 'youtube'
|
|
? <YouTubeIcon style={{ width: 14, height: 14, color: '#ff4444', flexShrink: 0 }} />
|
|
: <InstagramIcon style={{ width: 14, height: 14, color: '#e1306c', flexShrink: 0 }} />}
|
|
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{acc.channelTitle ?? acc.platformUsername ?? String(acc.id)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>계정을 선택하세요</span>
|
|
);
|
|
})()}
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{/* 드롭다운 목록 */}
|
|
{accountDropdownOpen && (
|
|
<div style={{
|
|
position: 'absolute', top: 'calc(100% + 4px)', left: 0,
|
|
zIndex: 9999,
|
|
background: '#001a1c',
|
|
border: '1px solid rgba(255,255,255,0.15)',
|
|
borderRadius: '8px',
|
|
overflow: 'hidden',
|
|
minWidth: '100%',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
}}>
|
|
{accounts.map((acc: ConnectedAccount) => (
|
|
<div
|
|
key={acc.id}
|
|
onClick={() => {
|
|
setSelectedAccountId(acc.id);
|
|
localStorage.setItem('castad_selected_account_id', String(acc.id));
|
|
setAccountDropdownOpen(false);
|
|
}}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '8px',
|
|
padding: '8px 12px',
|
|
cursor: 'pointer',
|
|
fontSize: '13px',
|
|
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
|
|
color: 'rgba(255,255,255,0.85)',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent'; }}
|
|
>
|
|
{acc.platform === 'youtube'
|
|
? <YouTubeIcon style={{ width: 14, height: 14, color: '#ff4444', flexShrink: 0 }} />
|
|
: <InstagramIcon style={{ width: 14, height: 14, color: '#e1306c', flexShrink: 0 }} />}
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{acc.channelTitle ?? acc.platformUsername ?? acc.platformUserId}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="dashboard-description">{t('dashboard.description')}</p>
|
|
</div>
|
|
<span className="dashboard-last-updated">{t('dashboard.lastUpdated')} {lastUpdated}</span>
|
|
</div>
|
|
</AnimatedSection>
|
|
|
|
{/* Content Performance Section */}
|
|
<AnimatedSection delay={100} className="dashboard-section">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="dashboard-section-title" style={{ marginBottom: 0 }}>{t('dashboard.contentPerformance')}</h2>
|
|
<div className="mode-toggle">
|
|
<button
|
|
className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
|
|
onClick={() => setMode('month')}
|
|
>
|
|
연간
|
|
</button>
|
|
<button
|
|
className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
|
|
onClick={() => setMode('day')}
|
|
>
|
|
월간
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="stats-grid-8">
|
|
{contentMetrics.map((metric: ContentMetric, index: number) => (
|
|
<AnimatedItem key={metric.id} index={index} baseDelay={200}>
|
|
<StatCard {...metric} />
|
|
</AnimatedItem>
|
|
))}
|
|
</div>
|
|
</AnimatedSection>
|
|
|
|
{/* Two Column Layout */}
|
|
<div className="dashboard-two-column">
|
|
{/* 추이 차트 섹션 (mode=month: 연간, mode=day: 일별) */}
|
|
<AnimatedSection delay={600}>
|
|
<div className="yoy-chart-card">
|
|
<div className="yoy-chart-header">
|
|
<h2 className="dashboard-section-title">{chartSectionTitle}</h2>
|
|
<div className="chart-legend-dual">
|
|
<div className="chart-legend-item">
|
|
<span className="chart-legend-line solid"></span>
|
|
<span className="chart-legend-text">{chartCurrentLabel}</span>
|
|
</div>
|
|
<div className="chart-legend-item">
|
|
<span className="chart-legend-line dashed"></span>
|
|
<span className="chart-legend-text">{chartPreviousLabel}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="yoy-chart-container">
|
|
<YearOverYearChart
|
|
data={chartData}
|
|
currentLabel={chartCurrentLabel}
|
|
previousLabel={chartPreviousLabel}
|
|
mode={mode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AnimatedSection>
|
|
|
|
{/* Top Content Section */}
|
|
<AnimatedSection delay={700}>
|
|
<div className="top-content-card">
|
|
<h2 className="dashboard-section-title">{t('dashboard.popularContent')}</h2>
|
|
<div className="top-content-list">
|
|
{topContent.map((content, index) => (
|
|
<AnimatedItem key={content.id} index={index} baseDelay={800}>
|
|
<TopContentItem {...content} />
|
|
</AnimatedItem>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</AnimatedSection>
|
|
</div>
|
|
|
|
{/* Audience Insights Section */}
|
|
<AnimatedSection delay={1000} className="audience-section">
|
|
<h2 className="dashboard-section-title">{t('dashboard.audienceInsights')}</h2>
|
|
{!isEmptyState && !hasRealAudienceData && (
|
|
<div className="mb-4 p-3 border-l-4 rounded" style={{ background: 'rgba(255,200,0,0.06)', borderColor: 'rgba(255,200,0,0.5)' }}>
|
|
<div className="flex items-center gap-2">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(255,200,0,0.8)" strokeWidth="2" style={{ flexShrink: 0 }}>
|
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>
|
|
누적 데이터가 부족하여 실제 시청자 데이터가 없습니다. 현재 샘플 데이터로 표시됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{audienceData ? (
|
|
<div className="audience-cards">
|
|
<AnimatedItem index={0} baseDelay={1100}>
|
|
<div className="audience-card">
|
|
<h3 className="audience-card-title">{t('dashboard.ageDistribution')}</h3>
|
|
<AudienceBarChart data={audienceData.ageGroups} delay={1200} />
|
|
</div>
|
|
</AnimatedItem>
|
|
<AnimatedItem index={1} baseDelay={1100}>
|
|
<div className="audience-card">
|
|
<h3 className="audience-card-title">{t('dashboard.genderDistribution')}</h3>
|
|
<GenderChart male={audienceData.gender.male} female={audienceData.gender.female} delay={1300} />
|
|
</div>
|
|
</AnimatedItem>
|
|
<AnimatedItem index={2} baseDelay={1100}>
|
|
<div className="audience-card">
|
|
<h3 className="audience-card-title">{t('dashboard.topRegions')}</h3>
|
|
<AudienceBarChart data={audienceData.topRegions.map(r => ({ label: r.region, percentage: r.percentage }))} delay={1400} />
|
|
</div>
|
|
</AnimatedItem>
|
|
</div>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '32px', opacity: 0.4 }}>
|
|
<span>{t('dashboard.noData') || '이 기간에 데이터가 없습니다.'}</span>
|
|
</div>
|
|
)}
|
|
</AnimatedSection>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DashboardContent;
|