453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { Clock, ListPlus, MoreVertical } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { PreviewMode } from '../types';
|
|
|
|
// YouTube official verified badge SVG
|
|
const VerifiedBadge = ({ className }: { className?: string }) => (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
focusable="false"
|
|
className={className}
|
|
style={{ pointerEvents: 'none', display: 'inline-block' }}
|
|
>
|
|
<g>
|
|
<path
|
|
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zM9.8 17.3l-4.2-4.1L7 11.8l2.8 2.7L17 7.4l1.4 1.4-8.6 8.5z"
|
|
fill="currentColor"
|
|
/>
|
|
</g>
|
|
</svg>
|
|
);
|
|
|
|
// YouTube-like avatar colors based on channel name
|
|
const AVATAR_COLORS = [
|
|
'#ef4444', // red
|
|
'#f97316', // orange
|
|
'#eab308', // yellow
|
|
'#22c55e', // green
|
|
'#14b8a6', // teal
|
|
'#3b82f6', // blue
|
|
'#8b5cf6', // violet
|
|
'#ec4899', // pink
|
|
'#6366f1', // indigo
|
|
'#06b6d4', // cyan
|
|
];
|
|
|
|
function getChannelColor(channelName: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < channelName.length; i++) {
|
|
hash = channelName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
|
}
|
|
|
|
interface YouTubeVideoCardProps {
|
|
/** Thumbnail image URL or data URL */
|
|
thumbnailUrl: string;
|
|
/** Video title */
|
|
title: string;
|
|
/** Channel name */
|
|
channelTitle: string;
|
|
/** Formatted view count (e.g., "1.2M views") */
|
|
viewCount?: string;
|
|
/** Relative time string (e.g., "3 days ago") */
|
|
publishedAt?: string;
|
|
/** Whether this is user's uploaded thumbnail (for highlighting) */
|
|
isUserThumbnail?: boolean;
|
|
/** Layout variant */
|
|
variant?: PreviewMode | 'search';
|
|
/** Video duration string */
|
|
duration?: string;
|
|
/** Optional channel avatar URL */
|
|
channelAvatarUrl?: string;
|
|
/** Whether the channel is verified */
|
|
isVerified?: boolean;
|
|
}
|
|
|
|
export const YouTubeVideoCard = ({
|
|
thumbnailUrl,
|
|
title,
|
|
channelTitle,
|
|
viewCount,
|
|
publishedAt,
|
|
variant = 'desktop',
|
|
duration = '10:30',
|
|
channelAvatarUrl,
|
|
isVerified = false,
|
|
}: YouTubeVideoCardProps) => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
// Format view count from string or use as-is
|
|
const formattedViews = viewCount || 'No views';
|
|
|
|
// Format date if it's a date string
|
|
const formattedTime = publishedAt || '';
|
|
|
|
// ============================================================================
|
|
// Sidebar Variant - YouTube "Up next" style (168x94px thumbnail)
|
|
// No avatar, horizontal layout, compact spacing
|
|
// ============================================================================
|
|
if (variant === 'sidebar') {
|
|
return (
|
|
<div className="yt-card flex cursor-pointer group" style={{ gap: '8px' }}>
|
|
{/* Thumbnail - 168x94px, 8px radius */}
|
|
<div
|
|
className="relative flex-shrink-0 overflow-hidden bg-yt-hover"
|
|
style={{
|
|
width: '168px',
|
|
height: '94px',
|
|
borderRadius: '8px',
|
|
}}
|
|
>
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
{/* Duration badge - bottom-right, 4px radius */}
|
|
<span
|
|
className="absolute text-white"
|
|
style={{
|
|
bottom: '4px',
|
|
right: '4px',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
padding: '3px 4px',
|
|
borderRadius: '4px',
|
|
lineHeight: 'normal',
|
|
}}
|
|
>
|
|
{duration}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Metadata - right side, no avatar */}
|
|
<div className="flex-1 min-w-0 pr-6 relative">
|
|
{/* Title - 14px/500, 2-line clamp */}
|
|
<h3
|
|
className="yt-title line-clamp-2"
|
|
style={{
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
lineHeight: '20px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
{title}
|
|
</h3>
|
|
{/* Channel name - 12px, meta color */}
|
|
<div className="flex items-center flex-wrap">
|
|
<p
|
|
className="yt-meta"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 400,
|
|
lineHeight: '18px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
{channelTitle}
|
|
</p>
|
|
{isVerified && (
|
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
|
)}
|
|
</div>
|
|
{/* Views and time */}
|
|
<p
|
|
className="yt-meta"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 400,
|
|
lineHeight: '18px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
{formattedViews} · {formattedTime}
|
|
</p>
|
|
|
|
{/* Action Menu (3 dots) */}
|
|
<button
|
|
className={cn(
|
|
'absolute top-0 right-[-8px] p-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
|
'hover:bg-yt-hover rounded-full'
|
|
)}
|
|
aria-label="Action menu"
|
|
>
|
|
<MoreVertical className="size-4 text-yt-title" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mobile Variant - Full-width thumbnail, 0px radius (edge-to-edge)
|
|
// Avatar below, 36x36px
|
|
// ============================================================================
|
|
if (variant === 'mobile') {
|
|
return (
|
|
<div className="yt-card cursor-pointer">
|
|
{/* Thumbnail - 100% width, 16:9, 0px radius */}
|
|
<div
|
|
className="relative w-full bg-yt-hover overflow-hidden"
|
|
style={{
|
|
aspectRatio: '16 / 9',
|
|
borderRadius: '0px',
|
|
}}
|
|
>
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
{/* Duration badge */}
|
|
<span
|
|
className="absolute text-white"
|
|
style={{
|
|
bottom: '4px',
|
|
right: '4px',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
padding: '3px 4px',
|
|
borderRadius: '4px',
|
|
lineHeight: 'normal',
|
|
}}
|
|
>
|
|
{duration}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Metadata - below thumbnail */}
|
|
<div className="flex px-3" style={{ gap: '12px', marginTop: '12px' }}>
|
|
{/* Avatar - 36x36px circle */}
|
|
<div
|
|
className="flex-shrink-0 overflow-hidden"
|
|
style={{
|
|
width: '36px',
|
|
height: '36px',
|
|
borderRadius: '50%',
|
|
}}
|
|
>
|
|
{channelAvatarUrl ? (
|
|
<img
|
|
src={channelAvatarUrl}
|
|
alt={channelTitle}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Text content */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Title - 14px/500, 2-line clamp */}
|
|
<h3
|
|
className="yt-title line-clamp-2"
|
|
style={{
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
lineHeight: '20px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
{title}
|
|
</h3>
|
|
{/* Channel, views, time - single line */}
|
|
<div
|
|
className="yt-meta flex items-center flex-wrap"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 400,
|
|
lineHeight: '18px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
<span>{channelTitle}</span>
|
|
{isVerified && (
|
|
<VerifiedBadge className="w-3 h-3 mx-1 text-yt-meta" />
|
|
)}
|
|
<span>
|
|
{' '}
|
|
· {formattedViews} · {formattedTime}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Menu - Always visible on mobile */}
|
|
<button className="flex-shrink-0 -mr-2 p-1 text-yt-title" aria-label="Action menu">
|
|
<MoreVertical className="size-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Desktop/Search Variant (default) - responsive thumbnail, 12px radius
|
|
// Avatar left, title/metadata right, hover states
|
|
// ============================================================================
|
|
return (
|
|
<div
|
|
className="yt-card cursor-pointer group w-full"
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{/* Thumbnail - responsive, 16:9 aspect ratio, 12px radius */}
|
|
<div
|
|
className="relative overflow-hidden bg-yt-hover w-full"
|
|
style={{
|
|
aspectRatio: '16 / 9',
|
|
borderRadius: '12px',
|
|
}}
|
|
>
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
|
|
{/* Duration badge - bottom-right */}
|
|
<span
|
|
className="absolute text-white"
|
|
style={{
|
|
bottom: '4px',
|
|
right: '4px',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
padding: '3px 4px',
|
|
borderRadius: '4px',
|
|
lineHeight: 'normal',
|
|
}}
|
|
>
|
|
{duration}
|
|
</span>
|
|
|
|
{/* Hover overlay with icons */}
|
|
{isHovered && (
|
|
<div className="absolute top-2 right-2 flex flex-col gap-1">
|
|
{/* Watch later button */}
|
|
<button
|
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
|
aria-label="Watch later"
|
|
>
|
|
<Clock className="size-4 text-white" />
|
|
</button>
|
|
{/* Add to queue button */}
|
|
<button
|
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
|
aria-label="Add to queue"
|
|
>
|
|
<ListPlus className="size-4 text-white" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Metadata - 12px gap from thumbnail */}
|
|
<div className="flex relative" style={{ gap: '12px', marginTop: '12px' }}>
|
|
{/* Avatar - 36x36px circle */}
|
|
<div
|
|
className="flex-shrink-0 overflow-hidden"
|
|
style={{
|
|
width: '36px',
|
|
height: '36px',
|
|
borderRadius: '50%',
|
|
}}
|
|
>
|
|
{channelAvatarUrl ? (
|
|
<img
|
|
src={channelAvatarUrl}
|
|
alt={channelTitle}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-full h-full flex items-center justify-center text-white font-medium"
|
|
style={{
|
|
backgroundColor: getChannelColor(channelTitle),
|
|
fontSize: '14px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
{channelTitle.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Text content */}
|
|
<div className="flex-1 min-w-0 pr-6">
|
|
{/* Title - 16px/500, 22px line-height, 2-line clamp */}
|
|
<h3
|
|
className="yt-title line-clamp-2"
|
|
style={{
|
|
fontSize: '16px',
|
|
fontWeight: 500,
|
|
lineHeight: '22px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
{title}
|
|
</h3>
|
|
|
|
{/* Channel name - 12px, meta color, hover effect */}
|
|
<div className="flex items-center flex-wrap">
|
|
<p
|
|
className={cn(
|
|
'yt-meta transition-colors cursor-pointer',
|
|
isHovered && 'text-yt-title'
|
|
)}
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 400,
|
|
lineHeight: '18px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
{channelTitle}
|
|
</p>
|
|
{isVerified && (
|
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Views and time */}
|
|
<p
|
|
className="yt-meta"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 400,
|
|
lineHeight: '18px',
|
|
fontFamily: 'Roboto, Arial, sans-serif',
|
|
}}
|
|
>
|
|
{formattedViews} · {formattedTime}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action Menu (3 dots) - Absolute positioned right */}
|
|
<button
|
|
className={cn(
|
|
'absolute top-0 right-[-8px] p-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
|
'hover:bg-yt-hover rounded-full'
|
|
)}
|
|
aria-label="Action menu"
|
|
>
|
|
<MoreVertical className="size-5 text-yt-title" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|