Image.tsx104 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import 'react-medium-image-zoom/dist/styles.css' |
| 4 | |
| 5 | import { useBreakpoint } from 'common' |
| 6 | import { useTheme } from 'next-themes' |
| 7 | import NextImage, { type ImageProps as NextImageProps } from 'next/image' |
| 8 | import { useEffect, useState } from 'react' |
| 9 | import Zoom from 'react-medium-image-zoom' |
| 10 | import { cn } from 'ui' |
| 11 | |
| 12 | import ZoomContent from './ZoomContent' |
| 13 | |
| 14 | export type CaptionAlign = 'left' | 'center' | 'right' |
| 15 | export interface StaticImageData { |
| 16 | src: string |
| 17 | height: number |
| 18 | width: number |
| 19 | blurDataURL?: string |
| 20 | blurWidth?: number |
| 21 | blurHeight?: number |
| 22 | } |
| 23 | |
| 24 | export interface StaticRequire { |
| 25 | default: StaticImageData |
| 26 | } |
| 27 | export type StaticImport = StaticRequire | StaticImageData |
| 28 | |
| 29 | export type SourceType = |
| 30 | | string |
| 31 | | { |
| 32 | dark: string | StaticImport |
| 33 | light: string | StaticImport |
| 34 | } |
| 35 | |
| 36 | export interface ImageProps extends Omit<NextImageProps, 'src'> { |
| 37 | src: SourceType |
| 38 | zoomable?: boolean |
| 39 | caption?: string |
| 40 | captionAlign?: CaptionAlign |
| 41 | containerClassName?: string |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * An advanced Image component that extends next/image with: |
| 46 | * - src: prop can either be a string or an object with theme alternatives {dark: string, light: string} |
| 47 | * - zoomable: {boolean} (optional) to make the image zoomable on click |
| 48 | * - caption: {string} (optional) to add a figcaption |
| 49 | * - captionAlign: {'left' | 'center' | 'right'} (optional) to align the caption |
| 50 | * - containerClassName: {string} (optional) to style the parent <figure> container |
| 51 | */ |
| 52 | export const Image = ({ src, alt = '', zoomable, ...props }: ImageProps) => { |
| 53 | const [mounted, setMounted] = useState(false) |
| 54 | const { resolvedTheme } = useTheme() |
| 55 | const isLessThanLgBreakpoint = useBreakpoint() |
| 56 | |
| 57 | const Component = zoomable ? Zoom : 'span' |
| 58 | const sizes = zoomable |
| 59 | ? '(max-width: 768px) 200vw, (max-width: 1200px) 120vw, 200vw' |
| 60 | : '(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 33vw' |
| 61 | const source = |
| 62 | typeof src === 'string' ? src : resolvedTheme?.includes('dark') ? src.dark : src.light |
| 63 | |
| 64 | useEffect(() => { |
| 65 | setMounted(true) |
| 66 | }, []) |
| 67 | |
| 68 | if (!mounted) return null |
| 69 | |
| 70 | return ( |
| 71 | <figure className={cn('next-image--dynamic-fill', props.containerClassName)}> |
| 72 | <Component |
| 73 | {...(zoomable |
| 74 | ? { ZoomContent: ZoomContent, zoomMargin: isLessThanLgBreakpoint ? 20 : 80 } |
| 75 | : undefined)} |
| 76 | > |
| 77 | <NextImage |
| 78 | key={resolvedTheme} |
| 79 | alt={alt} |
| 80 | src={source} |
| 81 | sizes={sizes} |
| 82 | className={props.className} |
| 83 | style={props.style} |
| 84 | {...props} |
| 85 | /> |
| 86 | </Component> |
| 87 | {props.caption && ( |
| 88 | <figcaption className={cn(getCaptionAlign(props.captionAlign))}>{props.caption}</figcaption> |
| 89 | )} |
| 90 | </figure> |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | const getCaptionAlign = (align?: CaptionAlign) => { |
| 95 | switch (align) { |
| 96 | case 'left': |
| 97 | return 'text-left' |
| 98 | case 'right': |
| 99 | return 'text-right' |
| 100 | case 'center': |
| 101 | default: |
| 102 | return 'text-center' |
| 103 | } |
| 104 | } |