index.tsx raw

   1  import { randomString } from '@/lib/random'
   2  import { cn } from '@/lib/utils'
   3  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
   4  import blossomService from '@/services/blossom.service'
   5  import modalManager from '@/services/modal-manager.service'
   6  import { TImetaInfo } from '@/types'
   7  import { ReactNode, useEffect, useMemo, useState } from 'react'
   8  import { createPortal } from 'react-dom'
   9  import Lightbox from 'yet-another-react-lightbox'
  10  import Zoom from 'yet-another-react-lightbox/plugins/zoom'
  11  import Image from '../Image'
  12  import ImageWithLightbox from '../ImageWithLightbox'
  13  
  14  export default function ImageGallery({
  15    className,
  16    images,
  17    start = 0,
  18    end = images.length,
  19    mustLoad = false
  20  }: {
  21    className?: string
  22    images: TImetaInfo[]
  23    start?: number
  24    end?: number
  25    mustLoad?: boolean
  26  }) {
  27    const id = useMemo(() => `image-gallery-${randomString()}`, [])
  28    const { autoLoadMedia } = useContentPolicy()
  29    const [index, setIndex] = useState(-1)
  30    const [slides, setSlides] = useState<{ src: string }[]>(images.map(({ url }) => ({ src: url })))
  31    useEffect(() => {
  32      if (index >= 0) {
  33        modalManager.register(id, () => {
  34          setIndex(-1)
  35        })
  36      } else {
  37        modalManager.unregister(id)
  38      }
  39    }, [index])
  40  
  41    useEffect(() => {
  42      const loadImages = async () => {
  43        const slides = await Promise.all(
  44          images.map(({ url, pubkey }) => {
  45            return new Promise<{ src: string }>((resolve) => {
  46              const img = new window.Image()
  47              let validUrl = url
  48              img.onload = () => {
  49                blossomService.markAsSuccess(url, validUrl)
  50                resolve({ src: validUrl })
  51              }
  52              img.onerror = () => {
  53                blossomService.tryNextUrl(url).then((nextUrl) => {
  54                  if (nextUrl) {
  55                    validUrl = nextUrl
  56                    resolve({ src: validUrl })
  57                  } else {
  58                    resolve({ src: url })
  59                  }
  60                })
  61              }
  62              if (pubkey) {
  63                blossomService
  64                  .getValidUrl(url, pubkey)
  65                  .then((u) => {
  66                    validUrl = u
  67                    img.src = validUrl
  68                  })
  69                  .catch(() => {
  70                    resolve({ src: url })
  71                  })
  72              } else {
  73                img.src = url
  74              }
  75            })
  76          })
  77        )
  78        setSlides(slides)
  79      }
  80  
  81      loadImages()
  82    }, [images])
  83  
  84    const handlePhotoClick = (event: React.MouseEvent, current: number) => {
  85      event.stopPropagation()
  86      event.preventDefault()
  87      setIndex(start + current)
  88    }
  89  
  90    const displayImages = images.slice(start, end)
  91  
  92    if (!mustLoad && !autoLoadMedia) {
  93      return displayImages.map((image, i) => (
  94        <ImageWithLightbox
  95          key={i}
  96          image={image}
  97          className="max-h-[80vh] sm:max-h-[50vh] object-contain"
  98          classNames={{
  99            wrapper: cn('w-fit max-w-full border', className)
 100          }}
 101        />
 102      ))
 103    }
 104  
 105    let imageContent: ReactNode | null = null
 106    if (displayImages.length === 1) {
 107      imageContent = (
 108        <Image
 109          key={0}
 110          className="max-h-[80vh] sm:max-h-[50vh] object-contain"
 111          classNames={{
 112            errorPlaceholder: 'aspect-square h-[30vh]',
 113            wrapper: 'cursor-zoom-in border'
 114          }}
 115          image={displayImages[0]}
 116          onClick={(e) => handlePhotoClick(e, 0)}
 117        />
 118      )
 119    } else if (displayImages.length === 2 || displayImages.length === 4) {
 120      imageContent = (
 121        <div className="grid grid-cols-2 gap-2 w-full">
 122          {displayImages.map((image, i) => (
 123            <Image
 124              key={i}
 125              className="aspect-square w-full"
 126              classNames={{ wrapper: 'cursor-zoom-in border' }}
 127              image={image}
 128              onClick={(e) => handlePhotoClick(e, i)}
 129            />
 130          ))}
 131        </div>
 132      )
 133    } else {
 134      imageContent = (
 135        <div className="grid grid-cols-3 gap-2 w-full">
 136          {displayImages.map((image, i) => (
 137            <Image
 138              key={i}
 139              className="aspect-square w-full"
 140              classNames={{ wrapper: 'cursor-zoom-in border' }}
 141              image={image}
 142              onClick={(e) => handlePhotoClick(e, i)}
 143            />
 144          ))}
 145        </div>
 146      )
 147    }
 148  
 149    return (
 150      <div className={cn(displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full', className)}>
 151        {imageContent}
 152        {index >= 0 &&
 153          createPortal(
 154            <div onClick={(e) => e.stopPropagation()}>
 155              <Lightbox
 156                index={index}
 157                slides={slides}
 158                plugins={[Zoom]}
 159                open={index >= 0}
 160                close={() => setIndex(-1)}
 161                controller={{
 162                  closeOnBackdropClick: true,
 163                  closeOnPullUp: true,
 164                  closeOnPullDown: true
 165                }}
 166                styles={{
 167                  toolbar: { paddingTop: '2.25rem' }
 168                }}
 169              />
 170            </div>,
 171            document.body
 172          )}
 173      </div>
 174    )
 175  }
 176