image optimazation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { findImage } from '~/utils/images';
|
||||
import LazyImage from '~/components/ui/LazyImage.astro';
|
||||
import {
|
||||
getImagesOptimized,
|
||||
astroAsseetsOptimizer,
|
||||
@@ -56,6 +57,15 @@ if (
|
||||
!image ? (
|
||||
<Fragment />
|
||||
) : (
|
||||
<img src={image.src} crossorigin="anonymous" referrerpolicy="no-referrer" {...image.attributes} />
|
||||
<LazyImage
|
||||
src={image.src}
|
||||
alt={props.alt}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
class={props.class}
|
||||
loading={props.loading}
|
||||
decoding={props.decoding}
|
||||
{...image.attributes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@@ -2,6 +2,8 @@
|
||||
// ImageModal.astro - A reusable modal component for displaying enlarged images
|
||||
---
|
||||
|
||||
import LazyImage from './LazyImage.astro';
|
||||
|
||||
<div
|
||||
id="image-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out"
|
||||
@@ -41,12 +43,14 @@
|
||||
style="min-height: 300px; max-height: 75vh;"
|
||||
>
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<img
|
||||
<LazyImage
|
||||
id="modal-image"
|
||||
src=""
|
||||
alt="Enlarged certificate"
|
||||
class="w-auto object-contain"
|
||||
style="max-height: var(--cert-max-height, 75%); vertical-align: middle;"
|
||||
blur={false}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
52
src/components/ui/LazyImage.astro
Normal file
52
src/components/ui/LazyImage.astro
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { LazyImageProps } from '~/utils/image';
|
||||
import { optimizeImage, generateSrcset, generateImageSizes } from '~/utils/image';
|
||||
|
||||
interface Props extends LazyImageProps {
|
||||
placeholder?: string;
|
||||
blur?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
class: className = '',
|
||||
loading = 'lazy',
|
||||
decoding = 'async',
|
||||
placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNlNWU3ZWYiLz48L3N2Zz4=',
|
||||
blur = true,
|
||||
} = Astro.props;
|
||||
|
||||
// Optimize image
|
||||
const optimizedImage = await optimizeImage({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
format: 'webp',
|
||||
quality: 80,
|
||||
});
|
||||
|
||||
// Generate srcset for responsive images
|
||||
const srcset = await generateSrcset(src);
|
||||
|
||||
// Generate sizes attribute
|
||||
const sizes = width ? generateImageSizes(width) : '100vw';
|
||||
|
||||
<img
|
||||
src={optimizedImage.src}
|
||||
srcset={srcset}
|
||||
sizes={sizes}
|
||||
alt={alt}
|
||||
width={optimizedImage.width}
|
||||
height={optimizedImage.height}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
class:list={[
|
||||
className,
|
||||
'transition-opacity duration-300',
|
||||
{ 'blur-sm': blur },
|
||||
]}
|
||||
onload="this.classList.remove('blur-sm')"
|
||||
style={`background-image: url('${placeholder}'); background-size: cover;`}
|
||||
/>
|
@@ -3,6 +3,7 @@ import Headline from '~/components/ui/Headline.astro';
|
||||
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
|
||||
import Button from '~/components/ui/Button.astro';
|
||||
import ImageModal from '~/components/ui/ImageModal.astro';
|
||||
import LazyImage from '~/components/ui/LazyImage.astro';
|
||||
import type { Testimonials as Props } from '~/types';
|
||||
import DefaultImage from '~/assets/images/default.png';
|
||||
|
||||
@@ -54,10 +55,13 @@ const {
|
||||
class="h-12 w-12 mr-3 flex-shrink-0 bg-gray-100 dark:bg-gray-800 rounded-md flex items-center justify-center overflow-hidden cursor-pointer"
|
||||
onclick={`window.openImageModal('${getImagePath(image)}', ${JSON.stringify(name || "Certification badge")})`}
|
||||
>
|
||||
<img
|
||||
<LazyImage
|
||||
src={getImagePath(image)}
|
||||
alt={getImageAlt(image, name || "Certification badge")}
|
||||
class="h-10 w-10 object-contain transition-transform duration-300 hover:scale-110"
|
||||
width={40}
|
||||
height={40}
|
||||
class="object-contain transition-transform duration-300 hover:scale-110"
|
||||
blur={false}
|
||||
/>
|
||||
</div>
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" class="flex-1">
|
||||
|
93
src/middleware.ts
Normal file
93
src/middleware.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
||||
import type { MiddlewareHandler } from 'astro';
|
||||
|
||||
// Rate limiter configuration
|
||||
const rateLimiter = new RateLimiterMemory({
|
||||
points: 10, // Number of requests
|
||||
duration: 60, // Per minute
|
||||
});
|
||||
|
||||
// Security headers configuration
|
||||
const securityHeaders = {
|
||||
'Content-Security-Policy': `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
block-all-mixed-content;
|
||||
upgrade-insecure-requests;
|
||||
`.replace(/\s+/g, ' ').trim(),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
};
|
||||
|
||||
// CORS configuration
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
};
|
||||
|
||||
export const onRequest: MiddlewareHandler = async ({ request, next }, response) => {
|
||||
// Handle preflight requests
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||
await rateLimiter.consume(ip);
|
||||
|
||||
// Add security headers
|
||||
Object.entries(securityHeaders).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
|
||||
// Add CORS headers
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
|
||||
// Add cache headers for static assets
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.match(/\.(jpg|jpeg|png|gif|ico|css|js)$/)) {
|
||||
response.headers.set('Cache-Control', 'public, max-age=31536000');
|
||||
} else {
|
||||
response.headers.set('Cache-Control', 'no-cache');
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
// Handle rate limit exceeded
|
||||
if (error.name === 'RateLimiterError') {
|
||||
return new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(Math.ceil(error.msBeforeNext / 1000)),
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
console.error('Middleware error:', error);
|
||||
return new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
};
|
100
src/utils/image.ts
Normal file
100
src/utils/image.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import sharp from 'sharp';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
interface OptimizeImageOptions {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: 'webp' | 'avif' | 'jpeg' | 'png';
|
||||
quality?: number;
|
||||
fit?: keyof sharp.FitEnum;
|
||||
}
|
||||
|
||||
export async function optimizeImage({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
format = 'webp',
|
||||
quality = 80,
|
||||
fit = 'cover',
|
||||
}: OptimizeImageOptions): Promise<ImageMetadata> {
|
||||
try {
|
||||
// Load the image
|
||||
const image = sharp(src);
|
||||
|
||||
// Get original metadata
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// Resize if dimensions are provided
|
||||
if (width || height) {
|
||||
image.resize({
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
fit,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to specified format
|
||||
switch (format) {
|
||||
case 'webp':
|
||||
image.webp({ quality });
|
||||
break;
|
||||
case 'avif':
|
||||
image.avif({ quality });
|
||||
break;
|
||||
case 'jpeg':
|
||||
image.jpeg({ quality });
|
||||
break;
|
||||
case 'png':
|
||||
image.png({ quality });
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate optimized buffer
|
||||
const optimizedBuffer = await image.toBuffer();
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
src: `data:image/${format};base64,${optimizedBuffer.toString('base64')}`,
|
||||
width: width || metadata.width || 0,
|
||||
height: height || metadata.height || 0,
|
||||
format,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Image optimization error:', error);
|
||||
throw new Error('Failed to optimize image');
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy loading image component props
|
||||
export interface LazyImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
class?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
decoding?: 'async' | 'sync' | 'auto';
|
||||
}
|
||||
|
||||
// Generate responsive image sizes
|
||||
export function generateImageSizes(width: number): string {
|
||||
return `(max-width: 640px) ${Math.min(width, 640)}px, ${width}px`;
|
||||
}
|
||||
|
||||
// Generate srcset for responsive images
|
||||
export async function generateSrcset(
|
||||
src: string,
|
||||
widths: number[] = [640, 768, 1024, 1280, 1536],
|
||||
format: 'webp' | 'avif' = 'webp'
|
||||
): Promise<string> {
|
||||
const srcset = await Promise.all(
|
||||
widths.map(async (width) => {
|
||||
const optimized = await optimizeImage({ src, width, format });
|
||||
return `${optimized.src} ${width}w`;
|
||||
})
|
||||
);
|
||||
|
||||
return srcset.join(', ');
|
||||
}
|
Reference in New Issue
Block a user