diff --git a/src/app/config.ts b/src/app/config.ts index f55d4577..ab1b7bb5 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -272,6 +272,14 @@ export const IMAGE_QUALITY = process.env.NEXT_PUBLIC_IMAGE_QUALITY ? parseInt(process.env.NEXT_PUBLIC_IMAGE_QUALITY) : 75; +// Load photos from storage CDN in the browser (skip /_next/image server proxy). +// Enabled by default when a public R2 domain is configured; set to 0 to disable. +export const DIRECT_STORAGE_IMAGES = + process.env.NEXT_PUBLIC_DIRECT_STORAGE_IMAGES === '1' || + ( + process.env.NEXT_PUBLIC_DIRECT_STORAGE_IMAGES !== '0' && + Boolean(process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN) + ); export const BLUR_ENABLED = process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1'; diff --git a/src/components/image/ImageWithFallback.tsx b/src/components/image/ImageWithFallback.tsx index d1936cf6..715b9199 100644 --- a/src/components/image/ImageWithFallback.tsx +++ b/src/components/image/ImageWithFallback.tsx @@ -1,8 +1,12 @@ 'use client'; /* eslint-disable jsx-a11y/alt-text */ -import { BLUR_ENABLED } from '@/app/config'; +import { BLUR_ENABLED, DIRECT_STORAGE_IMAGES } from '@/app/config'; import { useAppState } from '@/app/AppState'; +import { + getDirectPhotoDisplayUrl, + isExternalStoragePhotoUrl, +} from '@/photo/storage'; import { clsx} from 'clsx/lite'; import Image, { ImageProps } from 'next/image'; import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; @@ -26,11 +30,29 @@ export default function ImageWithFallback({ const [isLoading, setIsLoading] = useState(true); const [didError, setDidError] = useState(false); + const [fallbackSrc, setFallbackSrc] = useState(); const [fadeFallbackTransition, setFadeFallbackTransition] = useState(!hasLoadedWithAnimations); + const srcString = typeof props.src === 'string' ? props.src : undefined; + const useDirectStorage = DIRECT_STORAGE_IMAGES && + Boolean(srcString) && + isExternalStoragePhotoUrl(srcString!); + const displayWidth = typeof props.width === 'number' ? props.width : 640; + const directSrc = useDirectStorage && srcString && !fallbackSrc + ? getDirectPhotoDisplayUrl(srcString, displayWidth) + : undefined; + const resolvedSrc = fallbackSrc ?? directSrc ?? props.src; + const onLoad = useCallback(() => setIsLoading(false), []); - const onError = useCallback(() => setDidError(true), []); + const onError = useCallback(() => { + if (useDirectStorage && srcString && !fallbackSrc) { + setFallbackSrc(srcString); + setIsLoading(true); + } else { + setDidError(true); + } + }, [fallbackSrc, srcString, useDirectStorage]); useEffect(() => { if ( @@ -61,6 +83,8 @@ export default function ImageWithFallback({ > OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix ?? OPTIMIZED_SUFFIX_DEFAULT; +export const isExternalStoragePhotoUrl = (url: string) => + isUrlFromCloudflareR2(url) || + isUrlFromAwsS3(url) || + isUrlFromMinio(url); + +const getNextImageSizeForDisplayWidth = (displayWidth: number): NextImageSize => { + if (displayWidth <= 200) { return 200; } + if (displayWidth <= 640) { return 640; } + if (displayWidth <= 1080) { return 1080; } + if (displayWidth <= 1200) { return 1200; } + return 1920; +}; + +/** Public CDN URL for a photo (e.g. R2 -md/-sm), not /_next/image. */ +export const getDirectPhotoDisplayUrl = ( + imageUrl: string, + displayWidth: number, +) => + getOptimizedPhotoUrl({ + imageUrl, + size: getNextImageSizeForDisplayWidth(displayWidth), + useNextImage: false, + }); + export const getOptimizedPhotoUrl = ( args: Parameters[0] & { useNextImage?: boolean }, ) => { - const { useNextImage = true } = args; + const { useNextImage = !DIRECT_STORAGE_IMAGES } = args; const suffix = getSuffixFromNextImageSize(args.size); const { urlBase,