o2o-infinith-web/src/components/report/YouTubeAudit.tsx

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>
);
}