feat: Add ProgramsSection component for comprehensive student support
- Implemented ProgramsSection with various support programs including School Supplies, Clothing Support, Emergency Assistance, Educational Technology, Mentorship Programs, and Family Support Services. - Integrated framer-motion for animations and transitions. - Added a call-to-action button for requesting program support. test: Create unit tests for HeroSection component - Developed tests for rendering, accessibility, and functionality of the HeroSection component using Vitest and Testing Library. - Mocked framer-motion for testing purposes. refactor: Update sections index file to include ProgramsSection - Modified index.tsx to export ProgramsSection alongside existing sections. feat: Implement LazyImage component for optimized image loading - Created LazyImage component with lazy loading, error handling, and blur placeholder support. - Utilized framer-motion for loading animations. feat: Add PerformanceMonitor component for real-time performance metrics - Developed PerformanceMonitor to display web vitals and bundle performance metrics. - Included toggle functionality for development mode. feat: Create usePerformance hook for performance monitoring - Implemented usePerformance hook to track web vitals such as FCP, LCP, FID, CLS, and TTFB. - Added useBundlePerformance hook for monitoring bundle size and loading performance. test: Set up testing utilities and mocks for components - Established testing utilities for rendering components with context providers. - Mocked common hooks and framer-motion components for consistent testing. feat: Introduce bundleAnalyzer utility for analyzing bundle performance - Created BundleAnalyzer class to analyze bundle size, suggest optimizations, and generate reports. - Implemented helper functions for Vite integration and performance monitoring. chore: Configure Vitest for testing environment and coverage - Set up Vitest configuration with global variables, jsdom environment, and coverage thresholds.
This commit is contained in:
139
src/components/ui/LazyImage.tsx
Normal file
139
src/components/ui/LazyImage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useRef, useEffect, ImgHTMLAttributes } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface LazyImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'loading'> {
|
||||
src: string
|
||||
alt: string
|
||||
placeholder?: string
|
||||
blurDataURL?: string
|
||||
priority?: boolean
|
||||
sizes?: string
|
||||
quality?: number
|
||||
onLoadComplete?: () => void
|
||||
}
|
||||
|
||||
export function LazyImage({
|
||||
src,
|
||||
alt,
|
||||
placeholder = '/placeholder.svg',
|
||||
blurDataURL,
|
||||
priority = false,
|
||||
className = '',
|
||||
onLoadComplete,
|
||||
...props
|
||||
}: LazyImageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isInView, setIsInView] = useState(priority)
|
||||
const [error, setError] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const [imageSrc, setImageSrc] = useState(priority ? src : placeholder)
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (priority) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
setImageSrc(src)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin: '50px' }
|
||||
)
|
||||
|
||||
const currentImg = imgRef.current
|
||||
if (currentImg) {
|
||||
observer.observe(currentImg)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentImg) observer.unobserve(currentImg)
|
||||
}
|
||||
}, [src, priority])
|
||||
|
||||
// Handle image load
|
||||
const handleLoad = () => {
|
||||
setIsLoaded(true)
|
||||
onLoadComplete?.()
|
||||
}
|
||||
|
||||
// Handle image error
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
setImageSrc(placeholder)
|
||||
}
|
||||
|
||||
// Generate optimized src with quality and format
|
||||
const getOptimizedSrc = (originalSrc: string, quality = 85) => {
|
||||
// Check if it's already optimized or external
|
||||
if (originalSrc.includes('?') || originalSrc.startsWith('http')) {
|
||||
return originalSrc
|
||||
}
|
||||
|
||||
// Add quality parameter for supported formats
|
||||
if (originalSrc.includes('.jpg') || originalSrc.includes('.jpeg')) {
|
||||
return `${originalSrc}?quality=${quality}&format=webp`
|
||||
}
|
||||
|
||||
return originalSrc
|
||||
}
|
||||
|
||||
const optimizedSrc = getOptimizedSrc(imageSrc)
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<AnimatePresence>
|
||||
{blurDataURL && !isLoaded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundImage: `url(${blurDataURL})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(10px)',
|
||||
transform: 'scale(1.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.img
|
||||
ref={imgRef}
|
||||
src={optimizedSrc}
|
||||
alt={alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`w-full h-full object-cover ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={props.style}
|
||||
/>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{!isLoaded && !error && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-gray-300 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-2 bg-gray-300 rounded" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LazyImage
|
||||
211
src/components/ui/PerformanceMonitor.tsx
Normal file
211
src/components/ui/PerformanceMonitor.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { usePerformance, useBundlePerformance } from '@/hooks/usePerformance'
|
||||
import { Activity, Zap, Globe, Image, Code } from 'lucide-react'
|
||||
|
||||
interface PerformanceMonitorProps {
|
||||
showDetailed?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PerformanceMonitor({
|
||||
showDetailed = false,
|
||||
className = ''
|
||||
}: PerformanceMonitorProps) {
|
||||
const { metrics, isLoading } = usePerformance()
|
||||
const bundleMetrics = useBundlePerformance()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
// Toggle visibility in development mode
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
||||
setIsVisible(!isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
// Don't render in production unless explicitly requested
|
||||
if (process.env.NODE_ENV === 'production' && !showDetailed) return null
|
||||
if (!isVisible && process.env.NODE_ENV === 'development') return null
|
||||
|
||||
const getScoreColor = (value: number | null, thresholds: [number, number]) => {
|
||||
if (value === null) return 'text-gray-400'
|
||||
if (value <= thresholds[0]) return 'text-green-500'
|
||||
if (value <= thresholds[1]) return 'text-yellow-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
const formatMetric = (value: number | null, suffix = 'ms') => {
|
||||
return value ? `${Math.round(value)}${suffix}` : 'N/A'
|
||||
}
|
||||
|
||||
const webVitalsData = [
|
||||
{
|
||||
label: 'FCP',
|
||||
description: 'First Contentful Paint',
|
||||
value: metrics.FCP,
|
||||
threshold: [1800, 3000] as [number, number],
|
||||
icon: Activity,
|
||||
good: '< 1.8s',
|
||||
poor: '> 3.0s'
|
||||
},
|
||||
{
|
||||
label: 'LCP',
|
||||
description: 'Largest Contentful Paint',
|
||||
value: metrics.LCP,
|
||||
threshold: [2500, 4000] as [number, number],
|
||||
icon: Globe,
|
||||
good: '< 2.5s',
|
||||
poor: '> 4.0s'
|
||||
},
|
||||
{
|
||||
label: 'FID',
|
||||
description: 'First Input Delay',
|
||||
value: metrics.FID,
|
||||
threshold: [100, 300] as [number, number],
|
||||
icon: Zap,
|
||||
good: '< 100ms',
|
||||
poor: '> 300ms'
|
||||
},
|
||||
{
|
||||
label: 'CLS',
|
||||
description: 'Cumulative Layout Shift',
|
||||
value: metrics.CLS,
|
||||
threshold: [0.1, 0.25] as [number, number],
|
||||
icon: Activity,
|
||||
good: '< 0.1',
|
||||
poor: '> 0.25',
|
||||
suffix: ''
|
||||
},
|
||||
{
|
||||
label: 'TTFB',
|
||||
description: 'Time to First Byte',
|
||||
value: metrics.TTFB,
|
||||
threshold: [800, 1800] as [number, number],
|
||||
icon: Globe,
|
||||
good: '< 800ms',
|
||||
poor: '> 1.8s'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className={`fixed top-4 right-4 z-50 bg-white/95 backdrop-blur-sm border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
Measuring performance...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Web Vitals */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Core Web Vitals</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{webVitalsData.slice(0, 3).map((metric) => {
|
||||
const IconComponent = metric.icon
|
||||
const colorClass = getScoreColor(metric.value, metric.threshold)
|
||||
|
||||
return (
|
||||
<div key={metric.label} className="text-center">
|
||||
<div className="flex items-center justify-center mb-1">
|
||||
<IconComponent className={`w-3 h-3 ${colorClass}`} />
|
||||
</div>
|
||||
<div className={`text-xs font-mono ${colorClass}`}>
|
||||
{formatMetric(metric.value, metric.suffix || 'ms')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{metric.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Metrics */}
|
||||
{showDetailed && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Additional Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{webVitalsData.slice(3).map((metric) => {
|
||||
const colorClass = getScoreColor(metric.value, metric.threshold)
|
||||
return (
|
||||
<div key={metric.label} className="text-center">
|
||||
<div className={`text-xs font-mono ${colorClass}`}>
|
||||
{formatMetric(metric.value, metric.suffix || 'ms')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{metric.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundle Metrics */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Bundle Size</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Code className="w-3 h-3 text-blue-500" />
|
||||
JavaScript
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.jsSize}KB</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-sm" />
|
||||
CSS
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.cssSize}KB</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Image className="w-3 h-3 text-purple-500" />
|
||||
Images
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.imageSize}KB</span>
|
||||
</div>
|
||||
<div className="border-t pt-1 flex items-center justify-between text-xs font-medium">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{bundleMetrics.totalSize}KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="text-xs text-gray-500 border-t pt-2">
|
||||
Press <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Ctrl+Shift+P</kbd> to toggle
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PerformanceMonitor
|
||||
Reference in New Issue
Block a user