import {
forwardRef,
useEffect,
useRef,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type SyntheticEvent,
} from 'react'
import { BASE_PATH } from '@/lib/constants'
// Next/Image is a smart wrapper around `
` that handles automatic
// resizing, lazy loading, blur placeholders, and a CDN loader. Under
// Vite we don't run the Next image optimizer, so this shim degrades to
// a plain `
` while preserving the prop surface so consumer code
// compiles without modification.
//
// basePath: Next auto-prepends the configured basePath to absolute `src`
// values that point at app-served assets (the optimizer URL Next builds
// internally is itself prefixed). Our shim has no optimizer, so we
// prepend basePath directly on the rendered
for absolute
// paths. Full URLs (http:, data:, etc.) and already-prefixed paths are
// left alone. When a custom `loader` is provided the loader is
// responsible for the final URL — Next behaves the same way.
type ImageLoaderProps = { src: string; width: number; quality?: number }
type ImageLoader = (props: ImageLoaderProps) => string
interface ImageProps extends Omit, 'src' | 'alt' | 'loading'> {
src: string | { src: string; width?: number; height?: number }
alt: string
width?: number | `${number}`
height?: number | `${number}`
fill?: boolean
sizes?: string
priority?: boolean
loading?: 'lazy' | 'eager'
quality?: number | `${number}`
loader?: ImageLoader
placeholder?: 'blur' | 'empty' | `data:image/${string}`
blurDataURL?: string
unoptimized?: boolean
onLoadingComplete?: (img: HTMLImageElement) => void
}
function applyBasePath(src: string): string {
if (!BASE_PATH) return src
// Schemes (http:, https:, data:, blob:) and protocol-relative URLs.
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(src) || src.startsWith('//')) return src
// Already prefixed — happens when callers manually prepend BASE_PATH
// (several studio components do this today).
if (src === BASE_PATH || src.startsWith(`${BASE_PATH}/`)) return src
// Absolute app path — prepend.
if (src.startsWith('/')) return `${BASE_PATH}${src}`
// Relative path — leave alone.
return src
}
function resolveSrc(
src: ImageProps['src'],
width?: ImageProps['width'],
quality?: ImageProps['quality'],
loader?: ImageLoader
): string {
const raw = typeof src === 'string' ? src : src.src
if (loader) {
// Loader is the source of truth for the final URL — match Next and
// don't auto-prepend basePath. The loader receives the original src.
return loader({
src: raw,
width: typeof width === 'number' ? width : Number(width ?? 0),
quality: quality !== undefined ? Number(quality) : undefined,
})
}
return applyBasePath(raw)
}
const Image = forwardRef(function Image(
{
src,
width,
height,
fill,
sizes,
priority,
loading,
quality,
loader,
placeholder: _placeholder,
blurDataURL: _blurDataURL,
unoptimized: _unoptimized,
onLoad,
onLoadingComplete,
style,
...rest
}: ImageProps,
forwardedRef: ForwardedRef
) {
const innerRef = useRef(null)
// Keep the latest callback in a ref so firing doesn't depend on the
// caller memoizing onLoadingComplete.
const onLoadingCompleteRef = useRef(onLoadingComplete)
useEffect(() => {
onLoadingCompleteRef.current = onLoadingComplete
})
const resolvedSrc = resolveSrc(src, width, quality, loader)
// Mirror Next's onLoadingComplete, firing at most once per resolved src.
const firedForSrc = useRef(null)
const fireLoadingComplete = (img: HTMLImageElement) => {
if (firedForSrc.current === resolvedSrc) return
firedForSrc.current = resolvedSrc
onLoadingCompleteRef.current?.(img)
}
const handleLoad = (e: SyntheticEvent) => {
onLoad?.(e)
if (e.currentTarget) fireLoadingComplete(e.currentTarget)
}
// If the image is already cached (loaded synchronously before our
// handler attaches), fire on mount so the contract holds. Keyed on src
// so it re-arms when the image changes.
useEffect(() => {
const img = innerRef.current
if (img?.complete && img.naturalWidth > 0) fireLoadingComplete(img)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedSrc])
const finalStyle: CSSProperties | undefined = fill
? { position: 'absolute', inset: 0, width: '100%', height: '100%', ...style }
: style
return (
{
innerRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
src={resolvedSrc}
width={fill ? undefined : width}
height={fill ? undefined : height}
sizes={sizes}
// Match Next's defaults: lazy unless priority/loading says otherwise.
loading={loading ?? (priority ? 'eager' : 'lazy')}
fetchPriority={priority ? 'high' : rest.fetchPriority}
style={finalStyle}
onLoad={handleLoad}
/>
)
})
// eslint-disable-next-line no-restricted-exports
export default Image