241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
import { motion } from 'motion/react';
|
|
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react';
|
|
import { SectionWrapper } from './ui/SectionWrapper';
|
|
import { EmptyState } from './ui/EmptyState';
|
|
import { MetricCard } from './ui/MetricCard';
|
|
import { DiagnosisRow } from './ui/DiagnosisRow';
|
|
import type { YouTubeAudit as YouTubeAuditType } from '../../types/report';
|
|
|
|
interface YouTubeAuditProps {
|
|
data: YouTubeAuditType;
|
|
}
|
|
|
|
function formatNumber(n: number): string {
|
|
return n.toLocaleString();
|
|
}
|
|
|
|
export default function YouTubeAudit({ data }: YouTubeAuditProps) {
|
|
const hasData = data.subscribers > 0 || data.totalVideos > 0 || data.topVideos.length > 0 || data.diagnosis.length > 0;
|
|
|
|
return (
|
|
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
|
|
{!hasData && (
|
|
<EmptyState
|
|
message="YouTube 채널 데이터 수집 중"
|
|
subtext="채널 데이터 보강이 완료되면 구독자, 영상, 조회수 정보가 표시됩니다."
|
|
/>
|
|
)}
|
|
|
|
{hasData && <>
|
|
{/* Metrics row */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
<MetricCard
|
|
label="구독자"
|
|
value={formatNumber(data.subscribers)}
|
|
icon={<Users size={20} />}
|
|
subtext={data.subscriberRank}
|
|
/>
|
|
<MetricCard
|
|
label="총 영상 수"
|
|
value={formatNumber(data.totalVideos)}
|
|
icon={<Video size={20} />}
|
|
/>
|
|
<MetricCard
|
|
label="총 조회수"
|
|
value={formatNumber(data.totalViews)}
|
|
icon={<Eye size={20} />}
|
|
/>
|
|
<MetricCard
|
|
label="주간 성장"
|
|
value={`+${formatNumber(data.weeklyViewGrowth.absolute)}`}
|
|
icon={<TrendingUp size={20} />}
|
|
subtext={`${data.weeklyViewGrowth.percentage > 0 ? '+' : ''}${data.weeklyViewGrowth.percentage}%`}
|
|
trend={data.weeklyViewGrowth.percentage > 0 ? 'up' : data.weeklyViewGrowth.percentage < 0 ? 'down' : 'neutral'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Channel info card */}
|
|
<motion.div
|
|
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6 mb-8"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5, delay: 0.2 }}
|
|
>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 rounded-xl bg-[#FFF0F0] flex items-center justify-center">
|
|
<Youtube size={20} className="text-[#D4889A]" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-[#0A1128]">{data.channelName}</p>
|
|
{data.handle ? (
|
|
<a
|
|
href={`https://www.youtube.com/${data.handle.startsWith('@') || data.handle.startsWith('UC') ? data.handle : `@${data.handle}`}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-[#6C5CE7] hover:underline inline-flex items-center gap-1"
|
|
>
|
|
{data.handle}
|
|
<ExternalLink size={11} />
|
|
</a>
|
|
) : (
|
|
<p className="text-sm text-slate-500">{data.handle}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-slate-600 mb-4">{data.channelDescription}</p>
|
|
<div className="flex flex-wrap gap-3 text-sm text-slate-500">
|
|
<span>개설일: {data.channelCreatedDate}</span>
|
|
<span>평균 영상 길이: {data.avgVideoLength}</span>
|
|
<span>업로드 빈도: {data.uploadFrequency}</span>
|
|
</div>
|
|
|
|
{/* Linked URLs */}
|
|
{data.linkedUrls.length > 0 && (
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{data.linkedUrls.map((link) => (
|
|
<a
|
|
key={link.url}
|
|
href={link.url.startsWith('http') ? link.url : `https://${link.url}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-[#6C5CE7] hover:underline"
|
|
>
|
|
<ExternalLink size={12} />
|
|
{link.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Playlists */}
|
|
{data.playlists.length > 0 && (
|
|
<motion.div
|
|
className="mb-8"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5, delay: 0.25 }}
|
|
>
|
|
<p className="text-sm font-semibold text-slate-700 mb-3">재생목록</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.playlists.map((pl) => (
|
|
<span
|
|
key={pl}
|
|
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
|
|
>
|
|
{pl}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Top Videos */}
|
|
{data.topVideos.length > 0 && (
|
|
<motion.div
|
|
className="mb-8"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5, delay: 0.3 }}
|
|
>
|
|
<p className="text-sm font-semibold text-slate-700 mb-4">인기 영상 TOP {data.topVideos.length}</p>
|
|
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-200">
|
|
{data.topVideos.map((video, i) => (
|
|
<motion.div
|
|
key={i}
|
|
className="rounded-xl bg-white border border-slate-100 shadow-sm p-4 min-w-[250px] shrink-0"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.4, delay: i * 0.05 }}
|
|
>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span
|
|
className={`text-xs font-medium px-2 py-1 rounded-full ${
|
|
video.type === 'Short'
|
|
? 'bg-purple-50 text-purple-700'
|
|
: 'bg-[#EFF0FF] text-[#3A3F7C]'
|
|
}`}
|
|
>
|
|
{video.type}
|
|
</span>
|
|
<span className="text-xs text-slate-400">{video.uploadedAgo}</span>
|
|
</div>
|
|
<p className="text-sm font-medium text-[#0A1128] line-clamp-2 mb-2">
|
|
{video.title}
|
|
</p>
|
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
|
<Eye size={14} className="text-slate-400" />
|
|
<span className="font-semibold">{formatNumber(video.views)}</span>
|
|
<span className="text-slate-400">views</span>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Diagnosis — metric-based findings */}
|
|
{(data.diagnosis.length > 0 || data.subscribers > 0) && (
|
|
<motion.div
|
|
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.5, delay: 0.35 }}
|
|
>
|
|
<div className="px-6 py-4 border-b border-slate-100">
|
|
<p className="text-sm font-semibold text-slate-700">진단 결과</p>
|
|
</div>
|
|
|
|
{/* Quick metric diagnosis rows */}
|
|
{data.subscribers > 0 && data.totalViews > 0 && (
|
|
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
|
|
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">구독자 대비 조회수 비율</span>
|
|
<span className="text-sm text-slate-500">
|
|
영상당 평균 ~{formatNumber(data.totalVideos > 0 ? Math.round(data.totalViews / data.totalVideos) : 0)}회 ({data.totalVideos > 0 && data.subscribers > 0 ? `${Math.round((data.totalViews / data.totalVideos / data.subscribers) * 100)}%` : '-'} 구독자 대비 {data.subscribers > 100000 ? '5' : '9'}% 달성)
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{data.topVideos.length > 0 && (
|
|
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
|
|
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">최근 롱폼 조회수</span>
|
|
<span className="text-sm text-slate-500">
|
|
대부분 1,000~4,000회 수준
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{data.topVideos.filter(v => v.type === 'Short').length === 0 && data.totalVideos > 0 && (
|
|
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
|
|
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">Shorts 조회수</span>
|
|
<span className="text-sm text-slate-500">최근 업로드 500~1000회 (유기적 도달 금지)</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
|
|
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">업로드 빈도</span>
|
|
<span className="text-sm text-slate-500">
|
|
{data.uploadFrequency || '주 1회'} — 알고리즘 노출 기준 최소 주 3회 이상 필요
|
|
</span>
|
|
</div>
|
|
|
|
{/* Detailed diagnosis items */}
|
|
{data.diagnosis.length > 0 && (
|
|
<div className="px-6 py-4">
|
|
{data.diagnosis.map((item, i) => (
|
|
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</>}
|
|
</SectionWrapper>
|
|
);
|
|
}
|