Commit 4266de27 by Moorthy G

refactor(responsive-image): handle the image path to simplfy with '/images' or '/images/static'

parent 7655d6e5
NEXT_PUBLIC_API_URL=http://localhost/api
APP_ASSETS_URL=http://localhost/assets
......@@ -29,11 +29,11 @@ export const ArtDirection = {
render: (args: ResponsiveImageProps) => (
<ResponsiveImage {...args}>
<Source
media="xxl"
media="2xl"
src="https://picsum.photos/1920/540?jpg"
width={1920}
height={540}
sizes="xxl 100vw"
sizes="2xl 100vw"
/>
<Source
media="xl"
......
......@@ -2,6 +2,7 @@ import { forwardRef } from 'react';
import { getImageProps } from 'next/image';
import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet';
import { imageLoader, shouldOptimizeImage } from './imageLoader';
/**
* ResponsiveImage component for displaying optimized images with responsive srcSet
......@@ -15,6 +16,16 @@ import { processSrcSet } from './processSrcSet';
* src="/image.jpg"
* width={1920}
* height={1080}
* alt="Description"
* />
* ```
*
* @example With sizes attribute
* ```tsx
* <ResponsiveImage
* src="/image.jpg"
* width={1920}
* height={1080}
* sizes="100vw"
* alt="Description"
* />
......@@ -33,19 +44,21 @@ import { processSrcSet } from './processSrcSet';
* </ResponsiveImage>
* ```
*/
export interface ResponsiveImageProps
extends React.HTMLAttributes<HTMLImageElement> {
export interface ResponsiveImageProps extends React.HTMLAttributes<HTMLImageElement> {
src: string;
width: number;
height: number;
sizes: string;
sizes?: string;
alt?: string;
priority?: boolean;
quality?: number;
children?: React.ReactNode;
}
export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps>(
export const ResponsiveImage = forwardRef<
HTMLImageElement,
ResponsiveImageProps
>(
(
{
src,
......@@ -64,15 +77,18 @@ export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps
return null;
}
const shouldUnoptimize = !shouldOptimizeImage(src);
const processedSizes = processSizes(sizes);
const { props } = getImageProps({
src,
width,
height,
sizes: processedSizes,
quality,
priority,
alt
alt,
sizes: processedSizes,
loader: imageLoader,
...(shouldUnoptimize && { unoptimized: true })
});
const filteredSrcSet = processSrcSet(props.srcSet, width);
......
......@@ -3,13 +3,14 @@ import { getImageProps } from 'next/image';
import breakpoints, { type BreakpointKey } from './breakpoints';
import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet';
import { imageLoader, shouldOptimizeImage } from './imageLoader';
export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> {
src: string;
width: number;
height: number;
media: string;
sizes: string;
sizes?: string;
quality?: number;
}
......@@ -22,7 +23,7 @@ export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> {
*
* @example
* ```tsx
* <ResponsiveImage src="/default.jpg" width={576} height={540} sizes="100vw">
* <ResponsiveImage src="/default.jpg" width={576} height={540}>
* <Source
* media="lg"
* src="/large.jpg"
......@@ -57,15 +58,20 @@ export const Source = memo(
return null;
}
const query = (media in breakpoints ? breakpoints[media as BreakpointKey] : null) || media;
const shouldUnoptimize = !shouldOptimizeImage(src);
const query =
(media in breakpoints ? breakpoints[media as BreakpointKey] : null) ||
media;
const processedSizes = processSizes(sizes);
const { props } = getImageProps({
width,
height,
src,
sizes: processedSizes,
quality,
alt: ''
alt: '',
sizes: processedSizes,
loader: imageLoader,
...(shouldUnoptimize && { unoptimized: true })
});
const filteredSrcSet = processSrcSet(props.srcSet, width);
......
......@@ -3,7 +3,7 @@ const breakpoints = {
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)'
'2xl': '(min-width: 1536px)'
} as const;
export type BreakpointKey = keyof typeof breakpoints;
......
import type { ImageLoader } from 'next/image';
/**
* Image formats that can be optimized/processed
* These formats will be optimized, all other formats will be unoptimized
*/
export const processableFormats = ['.jpg', '.jpeg', '.png', '.webp', '.avif'];
/**
* Checks if an image source should be optimized based on its file extension
* @param src - The image source path
* @returns true if the image format is processable (should be optimized), false otherwise
*/
export const shouldOptimizeImage = (src: string): boolean => {
const pathname = src.split('?')[0];
return processableFormats.some((format) =>
pathname.toLowerCase().endsWith(format)
);
};
/**
* Custom image loader for Next.js ResponsiveImage component
* Expects src to be a path starting with /assets/ or /_next/static/media/
* Only processable image formats (jpg, jpeg, png, webp, avif) are transformed
* Other formats (svg, gif, etc.) are returned as-is
*/
export const imageLoader: ImageLoader = ({ src, width, quality }) => {
const pathname = src.split('?')[0];
const pathMappings: Record<string, string> = {
'/assets/': '/images/',
'/_next/static/media/': '/images/static/'
};
const basePath = Object.keys(pathMappings).find((path) =>
pathname.startsWith(path)
);
if (!basePath) {
return src;
}
if (!shouldOptimizeImage(src)) {
return src;
}
const q = quality ?? 75;
return `${pathname.replace(basePath, pathMappings[basePath])}?w=${width}&q=${q}`;
};
export * from './ResponsiveImage';
export * from './Source';
export * from './imageLoader';
......@@ -2,7 +2,7 @@ import breakpoints, { type BreakpointKey } from './breakpoints';
// Breakpoint order from largest to smallest for proper sizes attribute ordering
const breakpointOrderMap: Record<BreakpointKey, number> = {
xxl: 0,
'2xl': 0,
xl: 1,
lg: 2,
md: 3,
......@@ -14,7 +14,7 @@ const breakpointOrderMap: Record<BreakpointKey, number> = {
* and ordering them from largest to smallest breakpoint (required for correct evaluation)
* Example: "sm 640px, lg 50vw, 85vw" -> "(min-width: 1024px) 50vw, (min-width: 640px) 640px, 85vw"
*/
export function processSizes(sizes: string): string {
export function processSizes(sizes?: string): string | undefined {
if (!sizes) return sizes;
const parts = sizes.split(',');
......@@ -27,10 +27,10 @@ export function processSizes(sizes: string): string {
if (!trimmed) continue;
// Check if the part starts with a breakpoint name
const breakpointMatch = trimmed.match(/^(sm|md|lg|xl|xxl)\s+/);
const breakpointMatch = trimmed.match(/^(sm|md|lg|xl|2xl)\s+/);
if (breakpointMatch) {
const breakpointKey = breakpointMatch[1] as BreakpointKey;
// Runtime validation: ensure breakpoint exists
if (breakpointKey in breakpoints) {
const mediaQuery = breakpoints[breakpointKey];
......
/**
* Processes the srcSet string to exclude width descriptors greater than the actual image width
* Processes the srcSet string to include widths up to maxWidth, plus one size larger if maxWidth doesn't exactly match any width
* @param srcSet - The srcSet string from Next.js getImageProps (e.g., "/image.jpg?w=640 640w, /image.jpg?w=1024 1024w")
* @param maxWidth - The actual width of the image
* @returns Filtered srcSet string with only widths <= maxWidth
* @returns Filtered srcSet string with widths <= maxWidth, plus one size up only if maxWidth doesn't exactly match any width
* @example
* // maxWidth exactly matches a width - no next size up included
* processSrcSet("/img.jpg?w=640 640w, /img.jpg?w=1024 1024w, /img.jpg?w=1200 1200w", 1024)
* // Returns: "/img.jpg?w=640 640w, /img.jpg?w=1024 1024w"
*
* @example
* // maxWidth doesn't match any width - includes next size up
* processSrcSet("/img.jpg?w=640 640w, /img.jpg?w=1024 1024w, /img.jpg?w=1200 1200w", 1000)
* // Returns: "/img.jpg?w=640 640w, /img.jpg?w=1024 1024w"
*
* @example
* // maxWidth smaller than all widths - includes smallest width
* processSrcSet("/img.jpg?w=640 640w, /img.jpg?w=1024 1024w", 500)
* // Returns: "/img.jpg?w=640 640w"
*
* @example
* // maxWidth larger than all widths - includes all widths
* processSrcSet("/img.jpg?w=640 640w, /img.jpg?w=1024 1024w", 2000)
* // Returns: "/img.jpg?w=640 640w, /img.jpg?w=1024 1024w"
*
* @example
* // Density descriptors are always included
* processSrcSet("/img.jpg?w=640 640w, /img.jpg?w=1024 1024w, /img.jpg?w=1280 2x", 1000)
* // Returns: "/img.jpg?w=640 640w, /img.jpg?w=1024 1024w, /img.jpg?w=1280 2x"
*/
export function processSrcSet(srcSet: string | undefined, maxWidth: number): string | undefined {
export function processSrcSet(
srcSet: string | undefined,
maxWidth: number
): string | undefined {
if (!srcSet || typeof srcSet !== 'string') {
return srcSet;
}
// Split srcSet by comma to get individual entries
const entries = srcSet.split(',').map(entry => entry.trim()).filter(Boolean);
const entries = srcSet
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
// Filter and process each entry
const filteredEntries = entries
.map(entry => {
// Extract width descriptor (e.g., "640w" from "/image.jpg?w=640 640w")
// Also handle density descriptors (e.g., "2x") - these should be included as-is
const widthMatch = entry.match(/\s+(\d+)w$/);
const densityMatch = entry.match(/\s+(\d+(?:\.\d+)?)x$/);
// First pass: collect all entries with their widths and find the next size up
type EntryWithWidth = {
entry: string;
width: number | null;
};
// If it's a density descriptor (e.g., "2x"), include it as-is
if (densityMatch) {
return entry;
}
const entriesWithWidths: EntryWithWidth[] = entries.map((entry) => {
// Extract width descriptor (e.g., "640w" from "/image.jpg?w=640 640w")
const widthMatch = entry.match(/\s+(\d+)w$/);
const width = widthMatch ? parseInt(widthMatch[1], 10) : null;
return { entry, width };
});
// If it's a width descriptor, check against maxWidth
if (widthMatch) {
const width = parseInt(widthMatch[1], 10);
// Check if maxWidth exactly matches any width in the srcSet
const hasExactMatch = entriesWithWidths.some(
(e) => e.width !== null && e.width === maxWidth
);
// Include entry only if width is less than or equal to maxWidth
if (width <= maxWidth) {
return entry;
}
// Find the smallest width that is greater than maxWidth (one size up)
// Only include next size up if maxWidth doesn't exactly match any width
const widthsGreaterThanMax = entriesWithWidths
.filter((e) => e.width !== null && e.width > maxWidth)
.map((e) => e.width!);
const nextSizeUp =
!hasExactMatch && widthsGreaterThanMax.length > 0
? Math.min(...widthsGreaterThanMax)
: null;
return null;
// Filter entries: include widths <= maxWidth, plus the next size up if it exists and maxWidth doesn't match exactly
const filteredEntries = entriesWithWidths
.filter(({ width }) => {
// Include density descriptors (they don't have width, so width is null)
if (width === null) {
return true;
}
// If no descriptor found, include the entry as-is (fallback)
return entry;
// Include widths <= maxWidth
if (width <= maxWidth) {
return true;
}
// Include the next size up only if maxWidth doesn't exactly match any width
if (nextSizeUp !== null && width === nextSizeUp) {
return true;
}
return false;
})
.filter((entry): entry is string => entry !== null);
.map(({ entry }) => entry);
// Return the filtered srcSet or undefined if empty
return filteredEntries.length > 0 ? filteredEntries.join(', ') : undefined;
}
......@@ -3,24 +3,25 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import breakpoints, { type BreakpointKey } from './breakpoints';
export type IResponsiveVideoProps = React.VideoHTMLAttributes<HTMLVideoElement> & {
src?: string;
/**
* First match in the array will be selected.
* media should be a valid media query or a key from breakpoints object.
*/
srcSet?: {
src: string;
media: string;
}[];
/** auto plays video source */
autoPlay?: boolean;
/**
* If autoplay fails or src is not available and if there is a fallback,
* display this fallback element
*/
fallback?: React.ReactNode;
};
export type IResponsiveVideoProps =
React.VideoHTMLAttributes<HTMLVideoElement> & {
src?: string;
/**
* First match in the array will be selected.
* media should be a valid media query or a key from breakpoints object.
*/
srcSet?: {
src: string;
media: string;
}[];
/** auto plays video source */
autoPlay?: boolean;
/**
* If autoplay fails or src is not available and if there is a fallback,
* display this fallback element
*/
fallback?: React.ReactNode;
};
/**
* Resolves media query string from breakpoint key or returns the media query as-is.
......@@ -96,7 +97,8 @@ export const ResponsiveVideo = React.forwardRef<
const query = getMediaQuery(media);
return window.matchMedia(query).matches;
});
setCurrentSrc(matchedSet?.src || src);
const source = matchedSet?.src || src;
setCurrentSrc(source);
};
selectSource();
......@@ -137,7 +139,9 @@ export const ResponsiveVideo = React.forwardRef<
});
}, [autoPlay, currentSrc]);
const handleError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const handleError = (
event: React.SyntheticEvent<HTMLVideoElement, Event>
) => {
setHasError(true);
// Forward the error event to parent component's onError handler if provided
onError?.(event);
......@@ -145,7 +149,8 @@ export const ResponsiveVideo = React.forwardRef<
// Show fallback if: autoplay failed, video load error occurred, or no source available.
// Fallback is only shown if it's provided as a prop.
const shouldShowFallback = (isAutoPlayFailed || hasError || !currentSrc) && fallback;
const shouldShowFallback =
(isAutoPlayFailed || hasError || !currentSrc) && fallback;
if (shouldShowFallback) {
return fallback;
......@@ -156,12 +161,7 @@ export const ResponsiveVideo = React.forwardRef<
}
return (
<video
ref={setRefs}
src={currentSrc}
onError={handleError}
{...rest}
/>
<video ref={setRefs} src={currentSrc} onError={handleError} {...rest} />
);
});
......
......@@ -3,7 +3,7 @@ const breakpoints = {
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)'
'2xl': '(min-width: 1536px)'
} as const;
export type BreakpointKey = keyof typeof breakpoints;
......
......@@ -7,13 +7,16 @@ const getBundleAnalyzer = async () => {
return (config) => config;
};
/** Assets URL is required at build time */
const assetsUrl = new URL(process.env.APP_ASSETS_URL);
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 's3-ap-southeast-1.amazonaws.com'
protocol: assetsUrl.protocol.replace(':', ''),
hostname: assetsUrl.hostname
}
],
imageSizes: [64, 128, 256, 576, 768, 992, 1200, 1600, 1920, 2048, 3840]
......@@ -21,8 +24,16 @@ const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`
source: '/images/static/:path*',
destination: `/_next/image?url=${encodeURIComponent('/_next/static/media' + '/:path*')}`
},
{
source: '/images/:path*',
destination: `/_next/image?url=${encodeURIComponent(assetsUrl.toString() + '/:path*')}`
},
{
source: '/assets/:path*',
destination: `${assetsUrl.toString() + '/:path*'}`
}
];
},
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment