Image.tsx104 lines · main
1'use client'
2
3import 'react-medium-image-zoom/dist/styles.css'
4
5import { useBreakpoint } from 'common'
6import { useTheme } from 'next-themes'
7import NextImage, { type ImageProps as NextImageProps } from 'next/image'
8import { useEffect, useState } from 'react'
9import Zoom from 'react-medium-image-zoom'
10import { cn } from 'ui'
11
12import ZoomContent from './ZoomContent'
13
14export type CaptionAlign = 'left' | 'center' | 'right'
15export interface StaticImageData {
16 src: string
17 height: number
18 width: number
19 blurDataURL?: string
20 blurWidth?: number
21 blurHeight?: number
22}
23
24export interface StaticRequire {
25 default: StaticImageData
26}
27export type StaticImport = StaticRequire | StaticImageData
28
29export type SourceType =
30 | string
31 | {
32 dark: string | StaticImport
33 light: string | StaticImport
34 }
35
36export 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 */
52export 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
94const 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}