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 NEXT_PUBLIC_API_URL=http://localhost/api
APP_ASSETS_URL=http://localhost/assets
...@@ -29,11 +29,11 @@ export const ArtDirection = { ...@@ -29,11 +29,11 @@ export const ArtDirection = {
render: (args: ResponsiveImageProps) => ( render: (args: ResponsiveImageProps) => (
<ResponsiveImage {...args}> <ResponsiveImage {...args}>
<Source <Source
media="xxl" media="2xl"
src="https://picsum.photos/1920/540?jpg" src="https://picsum.photos/1920/540?jpg"
width={1920} width={1920}
height={540} height={540}
sizes="xxl 100vw" sizes="2xl 100vw"
/> />
<Source <Source
media="xl" media="xl"
......
...@@ -2,6 +2,7 @@ import { forwardRef } from 'react'; ...@@ -2,6 +2,7 @@ import { forwardRef } from 'react';
import { getImageProps } from 'next/image'; import { getImageProps } from 'next/image';
import { processSizes } from './processSizes'; import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet'; import { processSrcSet } from './processSrcSet';
import { imageLoader, shouldOptimizeImage } from './imageLoader';
/** /**
* ResponsiveImage component for displaying optimized images with responsive srcSet * ResponsiveImage component for displaying optimized images with responsive srcSet
...@@ -15,6 +16,16 @@ import { processSrcSet } from './processSrcSet'; ...@@ -15,6 +16,16 @@ import { processSrcSet } from './processSrcSet';
* src="/image.jpg" * src="/image.jpg"
* width={1920} * width={1920}
* height={1080} * height={1080}
* alt="Description"
* />
* ```
*
* @example With sizes attribute
* ```tsx
* <ResponsiveImage
* src="/image.jpg"
* width={1920}
* height={1080}
* sizes="100vw" * sizes="100vw"
* alt="Description" * alt="Description"
* /> * />
...@@ -33,19 +44,21 @@ import { processSrcSet } from './processSrcSet'; ...@@ -33,19 +44,21 @@ import { processSrcSet } from './processSrcSet';
* </ResponsiveImage> * </ResponsiveImage>
* ``` * ```
*/ */
export interface ResponsiveImageProps export interface ResponsiveImageProps extends React.HTMLAttributes<HTMLImageElement> {
extends React.HTMLAttributes<HTMLImageElement> {
src: string; src: string;
width: number; width: number;
height: number; height: number;
sizes: string; sizes?: string;
alt?: string; alt?: string;
priority?: boolean; priority?: boolean;
quality?: number; quality?: number;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps>( export const ResponsiveImage = forwardRef<
HTMLImageElement,
ResponsiveImageProps
>(
( (
{ {
src, src,
...@@ -64,15 +77,18 @@ export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps ...@@ -64,15 +77,18 @@ export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps
return null; return null;
} }
const shouldUnoptimize = !shouldOptimizeImage(src);
const processedSizes = processSizes(sizes); const processedSizes = processSizes(sizes);
const { props } = getImageProps({ const { props } = getImageProps({
src, src,
width, width,
height, height,
sizes: processedSizes,
quality, quality,
priority, priority,
alt alt,
sizes: processedSizes,
loader: imageLoader,
...(shouldUnoptimize && { unoptimized: true })
}); });
const filteredSrcSet = processSrcSet(props.srcSet, width); const filteredSrcSet = processSrcSet(props.srcSet, width);
......
...@@ -3,13 +3,14 @@ import { getImageProps } from 'next/image'; ...@@ -3,13 +3,14 @@ import { getImageProps } from 'next/image';
import breakpoints, { type BreakpointKey } from './breakpoints'; import breakpoints, { type BreakpointKey } from './breakpoints';
import { processSizes } from './processSizes'; import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet'; import { processSrcSet } from './processSrcSet';
import { imageLoader, shouldOptimizeImage } from './imageLoader';
export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> { export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> {
src: string; src: string;
width: number; width: number;
height: number; height: number;
media: string; media: string;
sizes: string; sizes?: string;
quality?: number; quality?: number;
} }
...@@ -22,7 +23,7 @@ export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> { ...@@ -22,7 +23,7 @@ export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> {
* *
* @example * @example
* ```tsx * ```tsx
* <ResponsiveImage src="/default.jpg" width={576} height={540} sizes="100vw"> * <ResponsiveImage src="/default.jpg" width={576} height={540}>
* <Source * <Source
* media="lg" * media="lg"
* src="/large.jpg" * src="/large.jpg"
...@@ -57,15 +58,20 @@ export const Source = memo( ...@@ -57,15 +58,20 @@ export const Source = memo(
return null; 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 processedSizes = processSizes(sizes);
const { props } = getImageProps({ const { props } = getImageProps({
width, width,
height, height,
src, src,
sizes: processedSizes,
quality, quality,
alt: '' alt: '',
sizes: processedSizes,
loader: imageLoader,
...(shouldUnoptimize && { unoptimized: true })
}); });
const filteredSrcSet = processSrcSet(props.srcSet, width); const filteredSrcSet = processSrcSet(props.srcSet, width);
......
...@@ -3,7 +3,7 @@ const breakpoints = { ...@@ -3,7 +3,7 @@ const breakpoints = {
md: '(min-width: 768px)', md: '(min-width: 768px)',
lg: '(min-width: 1024px)', lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)', xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)' '2xl': '(min-width: 1536px)'
} as const; } as const;
export type BreakpointKey = keyof typeof breakpoints; 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 './ResponsiveImage';
export * from './Source'; export * from './Source';
export * from './imageLoader';
...@@ -2,7 +2,7 @@ import breakpoints, { type BreakpointKey } from './breakpoints'; ...@@ -2,7 +2,7 @@ import breakpoints, { type BreakpointKey } from './breakpoints';
// Breakpoint order from largest to smallest for proper sizes attribute ordering // Breakpoint order from largest to smallest for proper sizes attribute ordering
const breakpointOrderMap: Record<BreakpointKey, number> = { const breakpointOrderMap: Record<BreakpointKey, number> = {
xxl: 0, '2xl': 0,
xl: 1, xl: 1,
lg: 2, lg: 2,
md: 3, md: 3,
...@@ -14,7 +14,7 @@ const breakpointOrderMap: Record<BreakpointKey, number> = { ...@@ -14,7 +14,7 @@ const breakpointOrderMap: Record<BreakpointKey, number> = {
* and ordering them from largest to smallest breakpoint (required for correct evaluation) * 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" * 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; if (!sizes) return sizes;
const parts = sizes.split(','); const parts = sizes.split(',');
...@@ -27,10 +27,10 @@ export function processSizes(sizes: string): string { ...@@ -27,10 +27,10 @@ export function processSizes(sizes: string): string {
if (!trimmed) continue; if (!trimmed) continue;
// Check if the part starts with a breakpoint name // 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) { if (breakpointMatch) {
const breakpointKey = breakpointMatch[1] as BreakpointKey; const breakpointKey = breakpointMatch[1] as BreakpointKey;
// Runtime validation: ensure breakpoint exists // Runtime validation: ensure breakpoint exists
if (breakpointKey in breakpoints) { if (breakpointKey in breakpoints) {
const mediaQuery = breakpoints[breakpointKey]; 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 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 * @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') { if (!srcSet || typeof srcSet !== 'string') {
return srcSet; return srcSet;
} }
// Split srcSet by comma to get individual entries // 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 // First pass: collect all entries with their widths and find the next size up
const filteredEntries = entries type EntryWithWidth = {
.map(entry => { entry: string;
// Extract width descriptor (e.g., "640w" from "/image.jpg?w=640 640w") width: number | null;
// 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$/);
// If it's a density descriptor (e.g., "2x"), include it as-is const entriesWithWidths: EntryWithWidth[] = entries.map((entry) => {
if (densityMatch) { // Extract width descriptor (e.g., "640w" from "/image.jpg?w=640 640w")
return entry; 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 // Check if maxWidth exactly matches any width in the srcSet
if (widthMatch) { const hasExactMatch = entriesWithWidths.some(
const width = parseInt(widthMatch[1], 10); (e) => e.width !== null && e.width === maxWidth
);
// Include entry only if width is less than or equal to maxWidth // Find the smallest width that is greater than maxWidth (one size up)
if (width <= maxWidth) { // Only include next size up if maxWidth doesn't exactly match any width
return entry; 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;
} }
// Include widths <= maxWidth
// If no descriptor found, include the entry as-is (fallback) if (width <= maxWidth) {
return entry; 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 the filtered srcSet or undefined if empty
return filteredEntries.length > 0 ? filteredEntries.join(', ') : undefined; return filteredEntries.length > 0 ? filteredEntries.join(', ') : undefined;
} }
...@@ -3,24 +3,25 @@ ...@@ -3,24 +3,25 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import breakpoints, { type BreakpointKey } from './breakpoints'; import breakpoints, { type BreakpointKey } from './breakpoints';
export type IResponsiveVideoProps = React.VideoHTMLAttributes<HTMLVideoElement> & { export type IResponsiveVideoProps =
src?: string; 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. * First match in the array will be selected.
*/ * media should be a valid media query or a key from breakpoints object.
srcSet?: { */
src: string; srcSet?: {
media: string; src: string;
}[]; media: string;
/** auto plays video source */ }[];
autoPlay?: boolean; /** auto plays video source */
/** autoPlay?: boolean;
* If autoplay fails or src is not available and if there is a fallback, /**
* display this fallback element * If autoplay fails or src is not available and if there is a fallback,
*/ * display this fallback element
fallback?: React.ReactNode; */
}; fallback?: React.ReactNode;
};
/** /**
* Resolves media query string from breakpoint key or returns the media query as-is. * Resolves media query string from breakpoint key or returns the media query as-is.
...@@ -96,7 +97,8 @@ export const ResponsiveVideo = React.forwardRef< ...@@ -96,7 +97,8 @@ export const ResponsiveVideo = React.forwardRef<
const query = getMediaQuery(media); const query = getMediaQuery(media);
return window.matchMedia(query).matches; return window.matchMedia(query).matches;
}); });
setCurrentSrc(matchedSet?.src || src); const source = matchedSet?.src || src;
setCurrentSrc(source);
}; };
selectSource(); selectSource();
...@@ -137,7 +139,9 @@ export const ResponsiveVideo = React.forwardRef< ...@@ -137,7 +139,9 @@ export const ResponsiveVideo = React.forwardRef<
}); });
}, [autoPlay, currentSrc]); }, [autoPlay, currentSrc]);
const handleError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => { const handleError = (
event: React.SyntheticEvent<HTMLVideoElement, Event>
) => {
setHasError(true); setHasError(true);
// Forward the error event to parent component's onError handler if provided // Forward the error event to parent component's onError handler if provided
onError?.(event); onError?.(event);
...@@ -145,7 +149,8 @@ export const ResponsiveVideo = React.forwardRef< ...@@ -145,7 +149,8 @@ export const ResponsiveVideo = React.forwardRef<
// Show fallback if: autoplay failed, video load error occurred, or no source available. // Show fallback if: autoplay failed, video load error occurred, or no source available.
// Fallback is only shown if it's provided as a prop. // 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) { if (shouldShowFallback) {
return fallback; return fallback;
...@@ -156,12 +161,7 @@ export const ResponsiveVideo = React.forwardRef< ...@@ -156,12 +161,7 @@ export const ResponsiveVideo = React.forwardRef<
} }
return ( return (
<video <video ref={setRefs} src={currentSrc} onError={handleError} {...rest} />
ref={setRefs}
src={currentSrc}
onError={handleError}
{...rest}
/>
); );
}); });
......
...@@ -3,7 +3,7 @@ const breakpoints = { ...@@ -3,7 +3,7 @@ const breakpoints = {
md: '(min-width: 768px)', md: '(min-width: 768px)',
lg: '(min-width: 1024px)', lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)', xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)' '2xl': '(min-width: 1536px)'
} as const; } as const;
export type BreakpointKey = keyof typeof breakpoints; export type BreakpointKey = keyof typeof breakpoints;
......
...@@ -7,13 +7,16 @@ const getBundleAnalyzer = async () => { ...@@ -7,13 +7,16 @@ const getBundleAnalyzer = async () => {
return (config) => config; return (config) => config;
}; };
/** Assets URL is required at build time */
const assetsUrl = new URL(process.env.APP_ASSETS_URL);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: assetsUrl.protocol.replace(':', ''),
hostname: 's3-ap-southeast-1.amazonaws.com' hostname: assetsUrl.hostname
} }
], ],
imageSizes: [64, 128, 256, 576, 768, 992, 1200, 1600, 1920, 2048, 3840] imageSizes: [64, 128, 256, 576, 768, 992, 1200, 1600, 1920, 2048, 3840]
...@@ -21,8 +24,16 @@ const nextConfig = { ...@@ -21,8 +24,16 @@ const nextConfig = {
async rewrites() { async rewrites() {
return [ return [
{ {
source: '/api/:path*', source: '/images/static/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL}/: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