Files
frontend/frontend/src/components/YouTubeVideoCard.tsx
2026-01-29 21:05:41 -03:00

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