Playlist
VideoCard Component
Documentation for the VideoCard component used in Playlist Analyzer
Overview
The VideoCard
component displays individual video information in a visually appealing card format. It includes thumbnail, title, statistics, and interactive elements with smooth animations.
Location
/src/components/VideoCard.tsx
Complete Implementation
'use client';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { VideoItem } from '@/lib/youtube';
import { ThumbsUp, Eye, Calendar, ExternalLink } from 'lucide-react';
import { Button } from './ui/button';
import { formatDuration, formatNumber } from '@/lib/ythelper';
interface VideoCardProps {
video: VideoItem;
searchQuery: string;
}
export function VideoCard({ video, searchQuery }: VideoCardProps) {
const highlightText = (text: string) => {
if (!searchQuery) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={index} className='bg-yellow-800'>
{part}
</span>
) : (
part
)
);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className='h-full'
>
<Card className='h-full overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-lg'>
<div className='relative'>
<img
src={video.thumbnails.medium.url || '/placeholder.svg'}
alt={video.title}
className='h-48 w-full object-cover'
/>
<div className='bg-opacity-70 absolute right-2 bottom-2 rounded bg-black px-2 py-1 text-xs font-medium text-white'>
{formatDuration(video.duration)}
</div>
</div>
<CardContent className='p-4'>
<h3 className='mb-2 line-clamp-2 h-14 text-lg font-semibold'>
{highlightText(video.title)}
</h3>
<div className='mt-2 flex flex-wrap gap-2'>
<Badge variant='secondary' className='flex items-center'>
<Eye className='mr-1 h-3 w-3 text-blue-500' />
<span>{formatNumber(video.viewCount)}</span>
</Badge>
<Badge variant='secondary' className='flex items-center'>
<ThumbsUp className='mr-1 h-3 w-3 text-green-500' />
<span>{formatNumber(video.likeCount)}</span>
</Badge>
<Badge variant='secondary' className='flex items-center'>
<Calendar className='mr-1 h-3 w-3 text-red-500' />
<span>{new Date(video.publishedAt).toLocaleDateString()}</span>
</Badge>
<Button
variant='secondary'
className='flex items-center space-x-1 hover:bg-blue-950'
onClick={() =>
window.open(
`https://youtube.com/watch?v=${video.id}`,
'_blank',
'noopener,noreferrer'
)
}
>
<ExternalLink className='h-3 w-3' />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}
Component Structure
motion.div (Animation wrapper)
└── Card (Shadcn/ui)
├── Thumbnail Section
│ ├── Image (Video thumbnail)
│ └── Duration Badge (Overlay)
└── CardContent
├── Title (With search highlighting)
└── Statistics Badges
├── Views Badge
├── Likes Badge
├── Publish Date Badge
└── External Link Button
Props
VideoCardProps
interface VideoCardProps {
video: VideoItem; // Video data object
searchQuery: string; // Current search query for highlighting
}
Properties:
Prop | Type | Required | Description |
---|---|---|---|
video | VideoItem | Yes | Complete video metadata |
searchQuery | string | Yes | Search term to highlight in title |
VideoItem Type
interface VideoItem {
id: string; // YouTube video ID
title: string; // Video title
description: string; // Video description
thumbnails: {
default: { url: string; width: number; height: number }
medium: { url: string; width: number; height: number }
high: { url: string; width: number; height: number }
};
channelTitle: string; // Channel name
publishedAt: string; // ISO 8601 date string
duration: number; // Duration in seconds
viewCount: number; // Total views
likeCount: number; // Total likes
}
Component Features
1. Thumbnail Section
<div className='relative'>
<img
src={video.thumbnails.medium.url || '/placeholder.svg'}
alt={video.title}
className='h-48 w-full object-cover'
/>
<div className='bg-opacity-70 absolute right-2 bottom-2 rounded bg-black px-2 py-1 text-xs font-medium text-white'>
{formatDuration(video.duration)}
</div>
</div>
Features:
- Image: 320x180 medium quality thumbnail
- Fallback:
/placeholder.svg
if thumbnail missing - Aspect Ratio: Fixed height (h-48), full width
- Duration Badge: Overlaid on bottom-right corner
- Positioning: Absolute positioning for overlay
Duration Format:
formatDuration(213) // Returns: "3m 33s"
formatDuration(3665) // Returns: "1h 1m 5s"
2. Title with Search Highlighting
<h3 className='mb-2 line-clamp-2 h-14 text-lg font-semibold'>
{highlightText(video.title)}
</h3>
Features:
- Line Clamping: Shows maximum 2 lines with ellipsis
- Fixed Height:
h-14
ensures consistent card heights - Search Highlighting: Matched text highlighted in yellow
highlightText Function
const highlightText = (text: string) => {
if (!searchQuery) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={index} className='bg-yellow-800'>
{part}
</span>
) : (
part
)
);
};
How It Works:
- Returns original text if no search query
- Splits text by search query (case-insensitive)
- Wraps matching parts in highlighted span
- Returns array of text and highlighted spans
Example:
searchQuery = "react"
title = "Learn React and React Native"
// Output:
// "Learn " + <span class="bg-yellow-800">React</span> + " and " + <span class="bg-yellow-800">React</span> + " Native"
3. Statistics Badges
Views Badge
<Badge variant='secondary' className='flex items-center'>
<Eye className='mr-1 h-3 w-3 text-blue-500' />
<span>{formatNumber(video.viewCount)}</span>
</Badge>
Icon: Eye (blue)
Format: Compact notation (1.2M, 500K, etc.)
Likes Badge
<Badge variant='secondary' className='flex items-center'>
<ThumbsUp className='mr-1 h-3 w-3 text-green-500' />
<span>{formatNumber(video.likeCount)}</span>
</Badge>
Icon: ThumbsUp (green)
Format: Compact notation
Publish Date Badge
<Badge variant='secondary' className='flex items-center'>
<Calendar className='mr-1 h-3 w-3 text-red-500' />
<span>{new Date(video.publishedAt).toLocaleDateString()}</span>
</Badge>
Icon: Calendar (red)
Format: Locale-specific date (e.g., "1/15/2024")
Date Formatting:
new Date('2024-01-15T10:00:00Z').toLocaleDateString()
// US: "1/15/2024"
// UK: "15/01/2024"
// ISO: "2024-01-15"
4. External Link Button
<Button
variant='secondary'
className='flex items-center space-x-1 hover:bg-blue-950'
onClick={() =>
window.open(
`https://youtube.com/watch?v=${video.id}`,
'_blank',
'noopener,noreferrer'
)
}
>
<ExternalLink className='h-3 w-3' />
</Button>
Features:
- Opens video in new tab
- Security:
noopener,noreferrer
flags - Hover effect: Blue background on hover
- Icon-only button for compact design
Generated URL:
`https://youtube.com/watch?v=${video.id}`
// Example: https://youtube.com/watch?v=dQw4w9WgXcQ
Animations
Entry Animation (Framer Motion)
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className='h-full'
>
Effect:
- Starts invisible and 20px below final position
- Fades in and slides up over 0.8 seconds
- Creates staggered effect when multiple cards load
Hover Animation (CSS)
className='transition-all duration-300 hover:-translate-y-1 hover:shadow-lg'
Effect:
- Card lifts up 4px on hover
- Shadow increases for depth effect
- Smooth 300ms transition
Visual Feedback:
Normal State: [Card]
↓
Hover State: [Card] ← Lifted with shadow
Responsive Behavior
Image Sizing
- Height: Fixed at
h-48
(192px) - Width: Full card width (
w-full
) - Object Fit:
object-cover
(crops to fill)
Card Height
- Height: Full height (
h-full
) - Min Height: Determined by content
- Consistency: Fixed title height ensures uniform cards
Badge Wrapping
className='mt-2 flex flex-wrap gap-2'
Behavior:
- Badges wrap to next line if needed
- 8px gap between badges
- Responsive to card width
Usage Examples
Basic Usage
import { VideoCard } from '@/components/VideoCard';
import type { VideoItem } from '@/lib/youtube';
function VideoList({ videos }: { videos: VideoItem[] }) {
return (
<div className='grid grid-cols-3 gap-4'>
{videos.map((video) => (
<VideoCard
key={video.id}
video={video}
searchQuery=''
/>
))}
</div>
);
}
With Search Query
import { VideoCard } from '@/components/VideoCard';
function SearchResults({ videos, query }: { videos: VideoItem[], query: string }) {
return (
<div className='grid grid-cols-3 gap-4'>
{videos.map((video) => (
<VideoCard
key={video.id}
video={video}
searchQuery={query} // Highlights matching text
/>
))}
</div>
);
}
In Playlist Analyzer
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'>
{sortedVideos.map((video) => (
<VideoCard
key={video.id}
video={video}
searchQuery={searchQuery}
/>
))}
</div>
Styling Details
Card Styling
className='h-full overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-lg'
Classes:
h-full
: Full height of containeroverflow-hidden
: Clips content at borders (rounded corners)transition-all
: Smooth transitions for all propertiesduration-300
: 300ms transition durationhover:-translate-y-1
: Lift on hoverhover:shadow-lg
: Large shadow on hover
Content Padding
<CardContent className='p-4'>
Padding: 16px on all sides
Title Styling
className='mb-2 line-clamp-2 h-14 text-lg font-semibold'
Classes:
mb-2
: 8px bottom marginline-clamp-2
: Maximum 2 lines with ellipsish-14
: Fixed 56px heighttext-lg
: 18px font sizefont-semibold
: 600 font weight
Badge Container
className='mt-2 flex flex-wrap gap-2'
Classes:
mt-2
: 8px top marginflex
: Flexbox layoutflex-wrap
: Wrap to multiple lines if neededgap-2
: 8px gap between items
Accessibility Features
Image Alt Text
<img
src={video.thumbnails.medium.url}
alt={video.title} // Descriptive alt text
/>
Button Accessibility
<Button
onClick={() => window.open(...)}
// Implicitly has role="button"
// Keyboard accessible (Enter/Space)
>
<ExternalLink className='h-3 w-3' />
</Button>
Semantic HTML
<h3>
for video title (proper heading hierarchy)<span>
for statistics text<button>
for clickable elements
Screen Reader Friendly
- Alt text on images
- Readable date format
- Icon + text combinations in badges
Performance Considerations
Image Loading
<img
src={video.thumbnails.medium.url || '/placeholder.svg'}
// Could add: loading="lazy" for lazy loading
/>
Optimization Opportunity:
<img
src={video.thumbnails.medium.url}
alt={video.title}
loading="lazy" // Lazy load images
className='h-48 w-full object-cover'
/>
Animation Performance
- Framer Motion uses GPU acceleration
- CSS transforms (
translate
,scale
) are hardware-accelerated - No expensive layout recalculations
Memoization Opportunity
For large lists, consider memoizing:
import { memo } from 'react';
export const VideoCard = memo(function VideoCard({ video, searchQuery }: VideoCardProps) {
// ... component code
}, (prevProps, nextProps) => {
return (
prevProps.video.id === nextProps.video.id &&
prevProps.searchQuery === nextProps.searchQuery
);
});
Customization Examples
Change Card Colors
// Modify icon colors
<Eye className='mr-1 h-3 w-3 text-blue-500' /> // Change to text-purple-500
<ThumbsUp className='mr-1 h-3 w-3 text-green-500' /> // Change to text-pink-500
<Calendar className='mr-1 h-3 w-3 text-red-500' /> // Change to text-orange-500
Adjust Card Height
<img
className='h-48 w-full object-cover' // Change h-48 to h-56 or h-64
/>
Change Hover Effect
// Stronger lift effect
className='hover:-translate-y-2 hover:shadow-2xl'
// Scale effect instead of lift
className='hover:scale-105 hover:shadow-lg'
Custom Duration Format
// Show hours:minutes:seconds format
<div className='...'>
{formatDurationForDisplay(video.duration)} // "15:23" instead of "15m 23s"
</div>
Testing
Unit Test Example
import { render, screen } from '@testing-library/react';
import { VideoCard } from './VideoCard';
const mockVideo: VideoItem = {
id: 'test123',
title: 'Test Video Title',
description: 'Test description',
thumbnails: {
medium: { url: 'test.jpg', width: 320, height: 180 }
},
channelTitle: 'Test Channel',
publishedAt: '2024-01-15T10:00:00Z',
duration: 300,
viewCount: 1000000,
likeCount: 50000
};
describe('VideoCard', () => {
test('renders video title', () => {
render(<VideoCard video={mockVideo} searchQuery='' />);
expect(screen.getByText('Test Video Title')).toBeInTheDocument();
});
test('highlights search query in title', () => {
render(<VideoCard video={mockVideo} searchQuery='Test' />);
const highlighted = screen.getByText('Test');
expect(highlighted).toHaveClass('bg-yellow-800');
});
test('formats view count correctly', () => {
render(<VideoCard video={mockVideo} searchQuery='' />);
expect(screen.getByText('1M')).toBeInTheDocument();
});
test('opens video in new tab on button click', () => {
const windowOpen = jest.spyOn(window, 'open');
const { container } = render(<VideoCard video={mockVideo} searchQuery='' />);
const button = container.querySelector('button');
button?.click();
expect(windowOpen).toHaveBeenCalledWith(
'https://youtube.com/watch?v=test123',
'_blank',
'noopener,noreferrer'
);
});
});
Dependencies
UI Components
Card
,CardContent
(Shadcn/ui)Badge
(Shadcn/ui)Button
(Shadcn/ui)
Icons
ThumbsUp
,Eye
,Calendar
,ExternalLink
(lucide-react)
Utilities
formatDuration
- Formats seconds to readable timeformatNumber
- Formats numbers to compact notation
Animation
motion
fromframer-motion
Types
VideoItem
from@/lib/youtube