index.tsx raw

   1  /**
   2   * Responsive Image Gallery
   3   *
   4   * Wraps ImageGallery with automatic responsive variant selection.
   5   * Displays appropriately-sized variants in the gallery and shows
   6   * full-size originals in the lightbox.
   7   */
   8  
   9  import { randomString } from '@/lib/random'
  10  import { cn } from '@/lib/utils'
  11  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
  12  import blossomService from '@/services/blossom.service'
  13  import responsiveImageService from '@/services/responsive-image.service'
  14  import modalManager from '@/services/modal-manager.service'
  15  import { TImetaInfo } from '@/types'
  16  import { UploadedVariant } from '@/lib/responsive-image-event'
  17  import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
  18  import { createPortal } from 'react-dom'
  19  import Lightbox from 'yet-another-react-lightbox'
  20  import Zoom from 'yet-another-react-lightbox/plugins/zoom'
  21  import Image from '../Image'
  22  import ImageWithLightbox from '../ImageWithLightbox'
  23  
  24  type EnhancedImageInfo = TImetaInfo & {
  25    displayInfo: TImetaInfo
  26    originalUrl: string
  27    variants: UploadedVariant[]
  28  }
  29  
  30  export default function ResponsiveImageGallery({
  31    className,
  32    images,
  33    start = 0,
  34    end = images.length,
  35    mustLoad = false
  36  }: {
  37    className?: string
  38    images: TImetaInfo[]
  39    start?: number
  40    end?: number
  41    mustLoad?: boolean
  42  }) {
  43    const id = useMemo(() => `responsive-image-gallery-${randomString()}`, [])
  44    const { autoLoadMedia } = useContentPolicy()
  45    const [index, setIndex] = useState(-1)
  46    const [enhancedImages, setEnhancedImages] = useState<EnhancedImageInfo[]>([])
  47    const [slides, setSlides] = useState<{ src: string }[]>([])
  48    const containerRef = useRef<HTMLDivElement>(null)
  49    const [containerWidth, setContainerWidth] = useState(0)
  50  
  51    // Measure container width for variant selection
  52    useEffect(() => {
  53      if (!containerRef.current) return
  54  
  55      const observer = new ResizeObserver((entries) => {
  56        for (const entry of entries) {
  57          setContainerWidth(entry.contentRect.width)
  58        }
  59      })
  60  
  61      observer.observe(containerRef.current)
  62      setContainerWidth(containerRef.current.offsetWidth)
  63  
  64      return () => observer.disconnect()
  65    }, [])
  66  
  67    // Register/unregister modal
  68    useEffect(() => {
  69      if (index >= 0) {
  70        modalManager.register(id, () => setIndex(-1))
  71      } else {
  72        modalManager.unregister(id)
  73      }
  74    }, [index])
  75  
  76    // Fetch variants and enhance images
  77    useEffect(() => {
  78      const enhanceImages = async () => {
  79        const enhanced = await Promise.all(
  80          images.map(async (image): Promise<EnhancedImageInfo> => {
  81            // Get sha256 from image or extract from URL
  82            const sha256 = image.sha256 ?? responsiveImageService.extractSha256FromUrl(image.url)
  83  
  84            if (!sha256) {
  85              return {
  86                ...image,
  87                displayInfo: image,
  88                originalUrl: image.url,
  89                variants: []
  90              }
  91            }
  92  
  93            const variants = await responsiveImageService.getVariantsForHash(sha256)
  94  
  95            if (!variants || variants.length === 0) {
  96              return {
  97                ...image,
  98                displayInfo: image,
  99                originalUrl: image.url,
 100                variants: []
 101              }
 102            }
 103  
 104            // Select variant for display (based on container or estimated width)
 105            const targetWidth = containerWidth > 0 ? containerWidth : 400
 106            const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1
 107            const displayVariant = responsiveImageService.selectVariant(variants, targetWidth, pixelRatio)
 108  
 109            // Get original for lightbox
 110            const originalVariant = responsiveImageService.getOriginalVariant(variants)
 111  
 112            const displayInfo: TImetaInfo = displayVariant
 113              ? {
 114                  url: displayVariant.url,
 115                  sha256: displayVariant.sha256,
 116                  blurHash: displayVariant.blurhash ?? image.blurHash,
 117                  thumbHash: image.thumbHash,
 118                  dim: { width: displayVariant.width, height: displayVariant.height },
 119                  pubkey: image.pubkey,
 120                  variant: displayVariant.variant
 121                }
 122              : image
 123  
 124            return {
 125              ...image,
 126              displayInfo,
 127              originalUrl: originalVariant?.url ?? image.url,
 128              variants
 129            }
 130          })
 131        )
 132  
 133        setEnhancedImages(enhanced)
 134      }
 135  
 136      enhanceImages()
 137    }, [images, containerWidth])
 138  
 139    // Build lightbox slides with original URLs
 140    useEffect(() => {
 141      const loadSlides = async () => {
 142        const newSlides = await Promise.all(
 143          enhancedImages.map(({ originalUrl, pubkey }) => {
 144            return new Promise<{ src: string }>((resolve) => {
 145              const img = new window.Image()
 146              let validUrl = originalUrl
 147  
 148              img.onload = () => {
 149                blossomService.markAsSuccess(originalUrl, validUrl)
 150                resolve({ src: validUrl })
 151              }
 152  
 153              img.onerror = () => {
 154                blossomService.tryNextUrl(originalUrl).then((nextUrl) => {
 155                  if (nextUrl) {
 156                    validUrl = nextUrl
 157                    resolve({ src: validUrl })
 158                  } else {
 159                    resolve({ src: originalUrl })
 160                  }
 161                })
 162              }
 163  
 164              if (pubkey) {
 165                blossomService
 166                  .getValidUrl(originalUrl, pubkey)
 167                  .then((u) => {
 168                    validUrl = u
 169                    img.src = validUrl
 170                  })
 171                  .catch(() => resolve({ src: originalUrl }))
 172              } else {
 173                img.src = originalUrl
 174              }
 175            })
 176          })
 177        )
 178        setSlides(newSlides)
 179      }
 180  
 181      if (enhancedImages.length > 0) {
 182        loadSlides()
 183      }
 184    }, [enhancedImages])
 185  
 186    const handlePhotoClick = (event: React.MouseEvent, current: number) => {
 187      event.stopPropagation()
 188      event.preventDefault()
 189      setIndex(start + current)
 190    }
 191  
 192    const displayImages = enhancedImages.slice(start, end)
 193  
 194    // Fall back to non-responsive display if auto-load is disabled
 195    if (!mustLoad && !autoLoadMedia) {
 196      return (
 197        <>
 198          {displayImages.map((image, i) => (
 199            <ImageWithLightbox
 200              key={i}
 201              image={image.displayInfo}
 202              className="max-h-[80vh] sm:max-h-[50vh] object-contain"
 203              classNames={{
 204                wrapper: cn('w-fit max-w-full border', className)
 205              }}
 206            />
 207          ))}
 208        </>
 209      )
 210    }
 211  
 212    let imageContent: ReactNode | null = null
 213  
 214    if (displayImages.length === 1) {
 215      imageContent = (
 216        <Image
 217          key={0}
 218          className="max-h-[80vh] sm:max-h-[50vh] object-contain"
 219          classNames={{
 220            errorPlaceholder: 'aspect-square h-[30vh]',
 221            wrapper: 'cursor-zoom-in border'
 222          }}
 223          image={displayImages[0].displayInfo}
 224          originalUrl={displayImages[0].originalUrl}
 225          onClick={(e) => handlePhotoClick(e, 0)}
 226        />
 227      )
 228    } else if (displayImages.length === 2 || displayImages.length === 4) {
 229      imageContent = (
 230        <div className="grid grid-cols-2 gap-2 w-full">
 231          {displayImages.map((image, i) => (
 232            <Image
 233              key={i}
 234              className="aspect-square w-full"
 235              classNames={{ wrapper: 'cursor-zoom-in border' }}
 236              image={image.displayInfo}
 237              originalUrl={image.originalUrl}
 238              onClick={(e) => handlePhotoClick(e, i)}
 239            />
 240          ))}
 241        </div>
 242      )
 243    } else {
 244      imageContent = (
 245        <div className="grid grid-cols-3 gap-2 w-full">
 246          {displayImages.map((image, i) => (
 247            <Image
 248              key={i}
 249              className="aspect-square w-full"
 250              classNames={{ wrapper: 'cursor-zoom-in border' }}
 251              image={image.displayInfo}
 252              originalUrl={image.originalUrl}
 253              onClick={(e) => handlePhotoClick(e, i)}
 254            />
 255          ))}
 256        </div>
 257      )
 258    }
 259  
 260    return (
 261      <div
 262        ref={containerRef}
 263        className={cn(displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full', className)}
 264      >
 265        {imageContent}
 266        {index >= 0 &&
 267          createPortal(
 268            <div onClick={(e) => e.stopPropagation()}>
 269              <Lightbox
 270                index={index}
 271                slides={slides}
 272                plugins={[Zoom]}
 273                open={index >= 0}
 274                close={() => setIndex(-1)}
 275                controller={{
 276                  closeOnBackdropClick: true,
 277                  closeOnPullUp: true,
 278                  closeOnPullDown: true
 279                }}
 280                styles={{
 281                  toolbar: { paddingTop: '2.25rem' }
 282                }}
 283              />
 284            </div>,
 285            document.body
 286          )}
 287      </div>
 288    )
 289  }
 290