Created
October 29, 2022 19:04
-
-
Save zachgoll/7a9fa440f68658791b9767c369685675 to your computer and use it in GitHub Desktop.
Next.js Image Gallery
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { NextPage } from 'next'; | |
import type { LightGallery } from 'lightgallery/lightgallery'; | |
import Image, { ImageLoaderProps } from 'next/future/image'; | |
import Masonry from 'react-masonry-css'; | |
// Import lightgallery with a couple nice-to-have plugins | |
import LightGalleryComponent from 'lightgallery/react'; | |
import 'lightgallery/css/lightgallery.css'; | |
import lgThumbnail from 'lightgallery/plugins/thumbnail'; | |
import 'lightgallery/css/lg-thumbnail.css'; | |
import lgZoom from 'lightgallery/plugins/zoom'; | |
import 'lightgallery/css/lg-zoom.css'; | |
import { useRef } from 'react'; | |
import lqip from 'lqip-modern'; // this helps us find the dimensions of a remote image and generates a LQIP (see below) | |
import axios from 'axios'; // we will need to fetch each image by it's URL for the LQIP | |
// NOTE: we've added some additional properties here since the last code block! | |
type ImageEnhanced = { | |
href: string; // used for the gallery main images | |
thumbnailHref: string; // used for the gallery thumbnails | |
// Used by the Next.js <Image /> component for our masonry layout (prior to opening the lightbox gallery) | |
src: string; | |
alt: string; | |
width: number; | |
height: number; | |
blurDataUrl: string; // this is our LQIP (low quality image placeholder) in a data-url format (see - https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs) | |
}; | |
type ImageGalleryPageProps = { | |
images: ImageEnhanced[]; | |
}; | |
/** | |
* A helper function that looks at an environment variable you've set to determine | |
* what URL to look for the images at. It also ensures the paths are of a standardized URL format. | |
*/ | |
function normalizeUrl(src: string) { | |
const MEDIA_URL = process.env['NEXT_PUBLIC_MEDIA_URL']; // set to whatever env you want | |
// Returns a URL object | |
if (src.slice(0, 4) === 'http') { | |
return new URL(src); | |
} else { | |
return new URL(`${MEDIA_URL}/${src[0] === '/' ? src.slice(1) : src}`); | |
} | |
} | |
/** | |
* Our Next.js Image loader | |
* | |
* This plays a VERY SIGNIFICANT role. | |
* | |
* It will: | |
* | |
* * Instruct Vercel (where we deploy) to *skip* its own image optimization and *instead* use Imgix for these duties | |
* * Grabs various image sizes and formats from Imgix for the given origin image `src`, which will then be used in the `srcset` of the <Image /> component | |
*/ | |
function imgixLoader({ src, width, quality }: ImageLoaderProps): string { | |
const url = normalizeUrl(src); // see fn above | |
const params = url.searchParams; | |
// Configure the Imgix params to get the appropriate image sizes and formats | |
params.set('auto', params.getAll('auto').join(',') || 'format'); | |
params.set('fit', params.get('fit') || 'max'); | |
params.set('w', params.get('w') || width.toString()); | |
if (quality) { | |
params.set('q', quality.toString()); | |
} | |
return url.href; // The absolute image url (i.e. https://www.someurl.com/image-1.png) | |
} | |
export const getStaticProps: GetStaticProps< | |
ImageGalleryPageProps | |
> = async () => { | |
/** | |
* In reality, you would have a real database that fetched a list of *relative* image paths | |
* | |
* Below is a mock implementation that returns a hardcoded list of image paths | |
*/ | |
const dummyDatabase = () => { | |
const imagePaths = ['/image-1.png', '/image-2.png', '/image-3.png']; | |
return { | |
getImagePaths: () => Promise.resolve(imagePaths), | |
}; | |
}; | |
// An array of relative image URLs | |
const imagePaths = await dummyDatabase.getImagePaths(); | |
const imagesWithMetadata: ImageEnhanced[] = []; | |
/** | |
* Loop through each image path and... | |
* | |
* 1) Grab the width and height of the image (so Next.js <Image /> knows what size to render for the masonry layout) | |
* 2) Grab a LQIP (low quality image placeholder) that will show as a placeholder while the full-size image is still loading over the network | |
* | |
*/ | |
for (const path of imagePaths) { | |
// Grab the URL object, which knows *where* to fetch our images (see top of file) | |
const url = normalizeUrl(path); | |
// To find the dimensions and prepare the LQIP, we need to make a GET request and grab the image in an "arraybuffer" encoding format | |
const imageResponse = await axios(url.href, { | |
responseType: 'arraybuffer', | |
}); | |
// Pass our Axios response data to lqip and let it do its magic! | |
const lqipData = await lqip(Buffer.from(imageResponse.data)); | |
// Generate our thumbnails dynamically with the Imgix API | |
const thumbnailUrl = normalizeUrl(path); | |
thumbnailUrl.searchParams.set('w', '200'); // all thumbnails will have same width of 200px | |
// Add the image with all its metadata to the array | |
imagesWithMetadata.push({ | |
href: url.href, | |
hrefThumbnail: thumbnailUrl.href, | |
src: url.pathname + url.search, // path + search params | |
alt: url.pathname, // I'm being lazy here, you should put something more descriptive in the alt attribute | |
width: lqipData.metadata.originalWidth, | |
height: lqipData.metadata.originalHeight, | |
blurDataUrl: lqipData.metadata.dataURIBase64, // our LQIP | |
}); | |
} | |
// Pass our enhanced image objects to the page props! | |
return { | |
props: { | |
images: imagesWithMetadata, | |
}, | |
}; | |
}; | |
// NOTE: I'm using TailwindCSS here | |
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => { | |
// This will allow us to grab the lightgallery JS instance, which will let us programmatically open the lightbox on clicks | |
const lightbox = useRef<LightGallery | null>(null); | |
return ( | |
<> | |
{/* Lightbox that opens on image clicks */} | |
<LightGalleryComponent | |
// Once the component initializes, we'll assign the instance to our React ref. This is used in the onClick() handler of each image in the Masonry layout | |
onInit={(ref) => { | |
if (ref) { | |
lightbox.current = ref.instance; | |
} | |
}} | |
plugins={[lgThumbnail, lgZoom]} | |
// These options turn the component into a "controlled" component that let's us determine when to open/close it | |
dynamic | |
dynamicEl={images.map((image) => ({ | |
src: image.href, | |
thumb: image.hrefThumbnail, | |
width: image.width.toString(), | |
alt: image.alt, | |
}))} | |
/> | |
<Masonry className="flex gap-2" columnClassName="bg-clip-padding"> | |
{images.map((image, idx) => ( | |
<Image | |
key={image.src} | |
className="hover:opacity-80 cursor-pointer my-2" | |
// Here, we're using the ref to dynamically open the gallery to the exact image that was clicked by the user | |
onClick={() => lightbox.current?.openGallery(idx)} | |
src={image.src} | |
alt={image.alt} | |
width={image.width} | |
height={image.height} | |
// This is our LQIP (low quality image placeholder) implementation (Next.js Image makes it super easy!) | |
placeholder="blur" | |
blurDataURL={image.dataUrl} | |
/> | |
))} | |
</Masonry> | |
</> | |
); | |
}; | |
export default ImageGalleryPage; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { NextPage } from 'next'; | |
import type { LightGallery } from 'lightgallery/lightgallery'; | |
import Image from 'next/future/image'; | |
import Masonry from 'react-masonry-css'; | |
// Import lightgallery with a couple nice-to-have plugins | |
import LightGalleryComponent from 'lightgallery/react'; | |
import 'lightgallery/css/lightgallery.css'; | |
import lgThumbnail from 'lightgallery/plugins/thumbnail'; | |
import 'lightgallery/css/lg-thumbnail.css'; | |
import lgZoom from 'lightgallery/plugins/zoom'; | |
import 'lightgallery/css/lg-zoom.css'; | |
import { useRef } from 'react'; | |
type ImageEnhanced = { | |
src: string; | |
alt: string; | |
width: number; | |
height: number; | |
}; | |
type ImageGalleryPageProps = { | |
images: ImageEnhanced[]; | |
}; | |
export const getStaticProps: GetStaticProps< | |
ImageGalleryPageProps | |
> = async () => { | |
return { | |
props: { | |
images: [ | |
{ | |
src: 'https://some-cdn.someurl.com/image-1.png', | |
alt: 'test image 1', | |
width: 1000, | |
height: 800, | |
}, | |
{ | |
src: 'https://some-cdn.someurl.com/image-2.png', | |
alt: 'test image 2', | |
width: 1000, | |
height: 800, | |
}, | |
], | |
}, | |
}; | |
}; | |
// NOTE: I'm using TailwindCSS here | |
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => { | |
// This will allow us to grab the lightgallery JS instance, which will let us programmatically open the lightbox on clicks | |
const lightbox = useRef<LightGallery | null>(null); | |
return ( | |
<> | |
{/* Lightbox that opens on image clicks */} | |
<LightGalleryComponent | |
// Once the component initializes, we'll assign the instance to our React ref. This is used in the onClick() handler of each image in the Masonry layout | |
onInit={(ref) => { | |
if (ref) { | |
lightbox.current = ref.instance; | |
} | |
}} | |
plugins={[lgThumbnail, lgZoom]} | |
// These options turn the component into a "controlled" component that let's us determine when to open/close it | |
dynamic | |
dynamicEl={images.map((image) => ({ | |
src: image.src, | |
thumb: image.src, | |
width: image.width.toString(), | |
alt: image.alt, | |
}))} | |
/> | |
<Masonry className="flex gap-2" columnClassName="bg-clip-padding"> | |
{images.map((img, idx) => ( | |
<Image | |
key={img.src} | |
className="hover:opacity-80 cursor-pointer my-2" | |
// Here, we're using the ref to dynamically open the gallery to the exact image that was clicked by the user | |
onClick={() => lightbox.current?.openGallery(idx)} | |
src={img.src} | |
alt={img.alt} | |
width={img.width} | |
height={img.height} | |
/> | |
))} | |
</Masonry> | |
</> | |
); | |
}; | |
export default ImageGalleryPage; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { NextPage } from 'next'; | |
import Image from 'next/future/image'; | |
import Masonry from 'react-masonry-css'; | |
type ImageEnhanced = { | |
src: string; | |
alt: string; | |
width: number; | |
height: number; | |
}; | |
type ImageGalleryPageProps = { | |
images: ImageEnhanced[]; | |
}; | |
export const getStaticProps: GetStaticProps< | |
ImageGalleryPageProps | |
> = async () => { | |
return { | |
props: { | |
images: [ | |
{ | |
src: 'https://some-cdn.someurl.com/image-1.png', | |
alt: 'test image 1', | |
width: 1000, | |
height: 800, | |
}, | |
{ | |
src: 'https://some-cdn.someurl.com/image-2.png', | |
alt: 'test image 2', | |
width: 1000, | |
height: 800, | |
}, | |
], | |
}, | |
}; | |
}; | |
// NOTE: I'm using TailwindCSS here | |
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => { | |
return ( | |
<Masonry> | |
{images.map((image) => ( | |
<Image | |
key={image.src} | |
className="hover:opacity-80 cursor-pointer my-2" | |
src={image.src} | |
alt={image.alt} | |
width={image.width} | |
height={image.height} | |
/> | |
))} | |
</Masonry> | |
); | |
}; | |
export default ImageGalleryPage; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment