- Updated package dependencies for improved localization support and performance. - Introduced CSS performance optimizations to prevent layout shifts and enhance rendering efficiency. - Implemented asset preloading and lazy loading strategies for critical components to improve load times. - Enhanced translation loading with optimized caching and background loading strategies. - Added performance monitoring utilities to track key metrics and improve user experience. - Refactored task management components to utilize new performance features and ensure efficient rendering. - Introduced new utility functions for asset and CSS optimizations to streamline resource management.
588 lines
15 KiB
TypeScript
588 lines
15 KiB
TypeScript
// Asset optimization utilities for improved performance
|
|
|
|
// Image optimization constants
|
|
export const IMAGE_OPTIMIZATION = {
|
|
// Quality settings for different use cases
|
|
QUALITY: {
|
|
THUMBNAIL: 70,
|
|
AVATAR: 80,
|
|
CONTENT: 85,
|
|
HIGH_QUALITY: 95,
|
|
},
|
|
|
|
// Size presets for responsive images
|
|
SIZES: {
|
|
THUMBNAIL: { width: 64, height: 64 },
|
|
AVATAR_SMALL: { width: 32, height: 32 },
|
|
AVATAR_MEDIUM: { width: 48, height: 48 },
|
|
AVATAR_LARGE: { width: 64, height: 64 },
|
|
ICON_SMALL: { width: 16, height: 16 },
|
|
ICON_MEDIUM: { width: 24, height: 24 },
|
|
ICON_LARGE: { width: 32, height: 32 },
|
|
CARD_IMAGE: { width: 300, height: 200 },
|
|
},
|
|
|
|
// Supported formats in order of preference
|
|
FORMATS: ['webp', 'jpeg', 'png'],
|
|
|
|
// Browser support detection
|
|
WEBP_SUPPORT: typeof window !== 'undefined' &&
|
|
window.document?.createElement('canvas').toDataURL('image/webp').indexOf('webp') > -1,
|
|
} as const;
|
|
|
|
// Asset caching strategies
|
|
export const CACHE_STRATEGIES = {
|
|
// Cache durations in seconds
|
|
DURATIONS: {
|
|
STATIC_ASSETS: 31536000, // 1 year
|
|
IMAGES: 2592000, // 30 days
|
|
AVATARS: 86400, // 1 day
|
|
DYNAMIC_CONTENT: 3600, // 1 hour
|
|
},
|
|
|
|
// Cache keys
|
|
KEYS: {
|
|
COMPRESSED_IMAGES: 'compressed_images',
|
|
AVATAR_CACHE: 'avatar_cache',
|
|
ICON_CACHE: 'icon_cache',
|
|
STATIC_ASSETS: 'static_assets',
|
|
},
|
|
} as const;
|
|
|
|
// Image compression utilities
|
|
export class ImageOptimizer {
|
|
private static canvas: HTMLCanvasElement | null = null;
|
|
private static ctx: CanvasRenderingContext2D | null = null;
|
|
|
|
private static getCanvas(): HTMLCanvasElement {
|
|
if (!this.canvas) {
|
|
this.canvas = document.createElement('canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
}
|
|
return this.canvas;
|
|
}
|
|
|
|
// Compress image with quality and size options
|
|
static async compressImage(
|
|
file: File | string,
|
|
options: {
|
|
quality?: number;
|
|
maxWidth?: number;
|
|
maxHeight?: number;
|
|
format?: 'jpeg' | 'webp' | 'png';
|
|
} = {}
|
|
): Promise<string> {
|
|
const {
|
|
quality = IMAGE_OPTIMIZATION.QUALITY.CONTENT,
|
|
maxWidth = 1920,
|
|
maxHeight = 1080,
|
|
format = 'jpeg',
|
|
} = options;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
|
|
img.onload = () => {
|
|
try {
|
|
const canvas = this.getCanvas();
|
|
const ctx = this.ctx!;
|
|
|
|
// Calculate optimal dimensions
|
|
const { width, height } = this.calculateOptimalSize(
|
|
img.width,
|
|
img.height,
|
|
maxWidth,
|
|
maxHeight
|
|
);
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
// Clear canvas and draw resized image
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Convert to optimized format
|
|
const mimeType = format === 'jpeg' ? 'image/jpeg' :
|
|
format === 'webp' ? 'image/webp' : 'image/png';
|
|
|
|
const compressedDataUrl = canvas.toDataURL(mimeType, quality / 100);
|
|
resolve(compressedDataUrl);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
img.onerror = reject;
|
|
|
|
if (typeof file === 'string') {
|
|
img.src = file;
|
|
} else {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
img.src = e.target?.result as string;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Calculate optimal size maintaining aspect ratio
|
|
private static calculateOptimalSize(
|
|
originalWidth: number,
|
|
originalHeight: number,
|
|
maxWidth: number,
|
|
maxHeight: number
|
|
): { width: number; height: number } {
|
|
const aspectRatio = originalWidth / originalHeight;
|
|
|
|
let width = originalWidth;
|
|
let height = originalHeight;
|
|
|
|
// Scale down if necessary
|
|
if (width > maxWidth) {
|
|
width = maxWidth;
|
|
height = width / aspectRatio;
|
|
}
|
|
|
|
if (height > maxHeight) {
|
|
height = maxHeight;
|
|
width = height * aspectRatio;
|
|
}
|
|
|
|
return {
|
|
width: Math.round(width),
|
|
height: Math.round(height),
|
|
};
|
|
}
|
|
|
|
// Generate responsive image srcSet
|
|
static generateSrcSet(
|
|
baseUrl: string,
|
|
sizes: Array<{ width: number; quality?: number }>
|
|
): string {
|
|
return sizes
|
|
.map(({ width, quality = IMAGE_OPTIMIZATION.QUALITY.CONTENT }) => {
|
|
const url = `${baseUrl}?w=${width}&q=${quality}${
|
|
IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : ''
|
|
}`;
|
|
return `${url} ${width}w`;
|
|
})
|
|
.join(', ');
|
|
}
|
|
|
|
// Create optimized avatar URL
|
|
static getOptimizedAvatarUrl(
|
|
baseUrl: string,
|
|
size: keyof typeof IMAGE_OPTIMIZATION.SIZES = 'AVATAR_MEDIUM'
|
|
): string {
|
|
const dimensions = IMAGE_OPTIMIZATION.SIZES[size];
|
|
const quality = IMAGE_OPTIMIZATION.QUALITY.AVATAR;
|
|
|
|
return `${baseUrl}?w=${dimensions.width}&h=${dimensions.height}&q=${quality}${
|
|
IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : ''
|
|
}`;
|
|
}
|
|
|
|
// Create optimized icon URL
|
|
static getOptimizedIconUrl(
|
|
baseUrl: string,
|
|
size: keyof typeof IMAGE_OPTIMIZATION.SIZES = 'ICON_MEDIUM'
|
|
): string {
|
|
const dimensions = IMAGE_OPTIMIZATION.SIZES[size];
|
|
|
|
return `${baseUrl}?w=${dimensions.width}&h=${dimensions.height}&q=100${
|
|
IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : ''
|
|
}`;
|
|
}
|
|
}
|
|
|
|
// Asset caching utilities
|
|
export class AssetCache {
|
|
private static cache = new Map<string, { data: any; timestamp: number; duration: number }>();
|
|
|
|
// Set item in cache with TTL
|
|
static set(key: string, data: any, duration: number = CACHE_STRATEGIES.DURATIONS.DYNAMIC_CONTENT): void {
|
|
this.cache.set(key, {
|
|
data,
|
|
timestamp: Date.now(),
|
|
duration: duration * 1000, // Convert to milliseconds
|
|
});
|
|
|
|
// Clean up expired items periodically
|
|
if (this.cache.size % 50 === 0) {
|
|
this.cleanup();
|
|
}
|
|
}
|
|
|
|
// Get item from cache
|
|
static get<T>(key: string): T | null {
|
|
const item = this.cache.get(key);
|
|
|
|
if (!item) return null;
|
|
|
|
// Check if expired
|
|
if (Date.now() - item.timestamp > item.duration) {
|
|
this.cache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
return item.data;
|
|
}
|
|
|
|
// Remove expired items
|
|
static cleanup(): void {
|
|
const now = Date.now();
|
|
for (const [key, item] of this.cache.entries()) {
|
|
if (now - item.timestamp > item.duration) {
|
|
this.cache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear all cache
|
|
static clear(): void {
|
|
this.cache.clear();
|
|
}
|
|
|
|
// Get cache size and statistics
|
|
static getStats(): { size: number; totalItems: number; hitRate: number } {
|
|
return {
|
|
size: this.cache.size,
|
|
totalItems: this.cache.size,
|
|
hitRate: 0, // Could be implemented with counters
|
|
};
|
|
}
|
|
}
|
|
|
|
// Lazy loading utilities
|
|
export class LazyLoader {
|
|
private static observer: IntersectionObserver | null = null;
|
|
private static loadedImages = new Set<string>();
|
|
|
|
// Initialize intersection observer
|
|
private static getObserver(): IntersectionObserver {
|
|
if (!this.observer) {
|
|
this.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target as HTMLImageElement;
|
|
this.loadImage(img);
|
|
this.observer?.unobserve(img);
|
|
}
|
|
});
|
|
},
|
|
{
|
|
rootMargin: '50px', // Start loading 50px before entering viewport
|
|
threshold: 0.1,
|
|
}
|
|
);
|
|
}
|
|
return this.observer;
|
|
}
|
|
|
|
// Setup lazy loading for an image
|
|
static setupLazyLoading(img: HTMLImageElement, src: string): void {
|
|
if (this.loadedImages.has(src)) {
|
|
img.src = src;
|
|
return;
|
|
}
|
|
|
|
img.dataset.src = src;
|
|
img.classList.add('lazy-loading');
|
|
this.getObserver().observe(img);
|
|
}
|
|
|
|
// Load image and handle caching
|
|
private static loadImage(img: HTMLImageElement): void {
|
|
const src = img.dataset.src;
|
|
if (!src) return;
|
|
|
|
// Check cache first
|
|
const cachedBlob = AssetCache.get<string>(`image_${src}`);
|
|
if (cachedBlob) {
|
|
img.src = cachedBlob;
|
|
img.classList.remove('lazy-loading');
|
|
img.classList.add('lazy-loaded');
|
|
this.loadedImages.add(src);
|
|
return;
|
|
}
|
|
|
|
// Load and cache image
|
|
const newImg = new Image();
|
|
newImg.onload = () => {
|
|
img.src = src;
|
|
img.classList.remove('lazy-loading');
|
|
img.classList.add('lazy-loaded');
|
|
this.loadedImages.add(src);
|
|
|
|
// Cache for future use
|
|
AssetCache.set(`image_${src}`, src, CACHE_STRATEGIES.DURATIONS.IMAGES);
|
|
};
|
|
newImg.onerror = () => {
|
|
img.classList.remove('lazy-loading');
|
|
img.classList.add('lazy-error');
|
|
};
|
|
newImg.src = src;
|
|
}
|
|
|
|
// Preload critical images
|
|
static preloadCriticalImages(urls: string[]): Promise<void[]> {
|
|
return Promise.all(
|
|
urls.map((url) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
this.loadedImages.add(url);
|
|
AssetCache.set(`image_${url}`, url, CACHE_STRATEGIES.DURATIONS.IMAGES);
|
|
resolve();
|
|
};
|
|
img.onerror = reject;
|
|
img.src = url;
|
|
});
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// Progressive loading utilities
|
|
export class ProgressiveLoader {
|
|
// Create progressive JPEG-like loading effect
|
|
static createProgressiveImage(
|
|
container: HTMLElement,
|
|
lowQualitySrc: string,
|
|
highQualitySrc: string
|
|
): void {
|
|
const lowQualityImg = document.createElement('img');
|
|
const highQualityImg = document.createElement('img');
|
|
|
|
// Style for smooth transition
|
|
const baseStyle = {
|
|
position: 'absolute' as const,
|
|
top: '0',
|
|
left: '0',
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover' as const,
|
|
};
|
|
|
|
Object.assign(lowQualityImg.style, baseStyle, {
|
|
filter: 'blur(2px)',
|
|
transition: 'opacity 0.3s ease',
|
|
});
|
|
|
|
Object.assign(highQualityImg.style, baseStyle, {
|
|
opacity: '0',
|
|
transition: 'opacity 0.3s ease',
|
|
});
|
|
|
|
// Load low quality first
|
|
lowQualityImg.src = lowQualitySrc;
|
|
container.appendChild(lowQualityImg);
|
|
container.appendChild(highQualityImg);
|
|
|
|
// Load high quality and fade in
|
|
highQualityImg.onload = () => {
|
|
highQualityImg.style.opacity = '1';
|
|
setTimeout(() => {
|
|
lowQualityImg.remove();
|
|
}, 300);
|
|
};
|
|
highQualityImg.src = highQualitySrc;
|
|
}
|
|
}
|
|
|
|
// Asset preloading strategies
|
|
export class AssetPreloader {
|
|
private static preloadedAssets = new Set<string>();
|
|
|
|
// Preload assets based on priority
|
|
static preloadAssets(assets: Array<{ url: string; priority: 'high' | 'medium' | 'low' }>): void {
|
|
// Sort by priority
|
|
assets.sort((a, b) => {
|
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
|
});
|
|
|
|
// Preload high priority assets immediately
|
|
const highPriorityAssets = assets.filter(asset => asset.priority === 'high');
|
|
this.preloadImmediately(highPriorityAssets.map(a => a.url));
|
|
|
|
// Preload medium priority assets after a short delay
|
|
setTimeout(() => {
|
|
const mediumPriorityAssets = assets.filter(asset => asset.priority === 'medium');
|
|
this.preloadWithIdleCallback(mediumPriorityAssets.map(a => a.url));
|
|
}, 100);
|
|
|
|
// Preload low priority assets when browser is idle
|
|
setTimeout(() => {
|
|
const lowPriorityAssets = assets.filter(asset => asset.priority === 'low');
|
|
this.preloadWithIdleCallback(lowPriorityAssets.map(a => a.url));
|
|
}, 1000);
|
|
}
|
|
|
|
// Immediate preloading for critical assets
|
|
private static preloadImmediately(urls: string[]): void {
|
|
urls.forEach(url => {
|
|
if (this.preloadedAssets.has(url)) return;
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'preload';
|
|
link.href = url;
|
|
|
|
// Determine asset type
|
|
if (url.match(/\.(jpg|jpeg|png|webp|gif)$/i)) {
|
|
link.as = 'image';
|
|
} else if (url.match(/\.(woff|woff2|ttf|otf)$/i)) {
|
|
link.as = 'font';
|
|
link.crossOrigin = 'anonymous';
|
|
} else if (url.match(/\.(css)$/i)) {
|
|
link.as = 'style';
|
|
} else if (url.match(/\.(js)$/i)) {
|
|
link.as = 'script';
|
|
}
|
|
|
|
document.head.appendChild(link);
|
|
this.preloadedAssets.add(url);
|
|
});
|
|
}
|
|
|
|
// Preload with idle callback for non-critical assets
|
|
private static preloadWithIdleCallback(urls: string[]): void {
|
|
const preloadBatch = () => {
|
|
urls.forEach(url => {
|
|
if (this.preloadedAssets.has(url)) return;
|
|
|
|
const img = new Image();
|
|
img.src = url;
|
|
this.preloadedAssets.add(url);
|
|
});
|
|
};
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
(window as any).requestIdleCallback(preloadBatch, { timeout: 2000 });
|
|
} else {
|
|
setTimeout(preloadBatch, 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
// CSS for optimized image loading
|
|
export const imageOptimizationStyles = `
|
|
/* Lazy loading states */
|
|
.lazy-loading {
|
|
background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.lazy-loaded {
|
|
animation: fadeIn 0.3s ease-in-out;
|
|
}
|
|
|
|
.lazy-error {
|
|
background-color: #f5f5f5;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.lazy-error::after {
|
|
content: '⚠️';
|
|
font-size: 24px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Shimmer animation for loading */
|
|
@keyframes shimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
|
|
/* Fade in animation */
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
/* Progressive image container */
|
|
.progressive-image {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background-color: #f5f5f5;
|
|
}
|
|
|
|
/* Responsive image utilities */
|
|
.responsive-image {
|
|
width: 100%;
|
|
height: auto;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Avatar optimization */
|
|
.optimized-avatar {
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
background-color: #e5e7eb;
|
|
}
|
|
|
|
/* Icon optimization */
|
|
.optimized-icon {
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* Preload critical images */
|
|
.critical-image {
|
|
object-fit: cover;
|
|
background-color: #f5f5f5;
|
|
}
|
|
`;
|
|
|
|
// Utility functions
|
|
export const AssetUtils = {
|
|
// Get file size from data URL
|
|
getDataUrlSize: (dataUrl: string): number => {
|
|
const base64String = dataUrl.split(',')[1];
|
|
return Math.round((base64String.length * 3) / 4);
|
|
},
|
|
|
|
// Convert file size to human readable format
|
|
formatFileSize: (bytes: number): string => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
},
|
|
|
|
// Generate low quality placeholder
|
|
generatePlaceholder: (width: number, height: number, color: string = '#e5e7eb'): string => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
return canvas.toDataURL('image/png');
|
|
},
|
|
|
|
// Check if image is already cached
|
|
isImageCached: (url: string): boolean => {
|
|
return AssetCache.get(`image_${url}`) !== null;
|
|
},
|
|
|
|
// Prefetch critical resources
|
|
prefetchResources: (urls: string[]): void => {
|
|
urls.forEach(url => {
|
|
const link = document.createElement('link');
|
|
link.rel = 'prefetch';
|
|
link.href = url;
|
|
document.head.appendChild(link);
|
|
});
|
|
},
|
|
};
|