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