Photography Portfolio
A website to display my photography
One of my hobbies is photography, and I wanted a way to display my best pictures, and so decided to build a website for them. I chose Contentful for this as they by far have the largest bandwidth at .75 TB, and also have image optimization. Another good option for this would be Prismic as they have 100 GB of bandwidth but offer better image optimization using imgix, this would allow for serving the best format of images based on the browser, something Contentful doesn't offer.
Contentful has also recently launched a GraphQL API for fetching the content, which I much prefer over a REST API for this kind of request. As I was fetching data through getStaticProps
in Next.js, performant features for GraphQL weren't important for me, so I just used the native fetch
like this:
async function fetchGraphQL(query, { variables } = {}, preview = false) {
return fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/master`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${
preview
? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN
: process.env.CONTENTFUL_ACCESS_TOKEN
}`,
},
body: JSON.stringify({ query, variables }),
}
).then((response) => response.json());
}
Then I pass in the GraphQL query using the query
parameter, and if I need to use variables, these could also be supplied.
For this project I wanted to use the new Next.js Image
component to optimize the images and load them optimally, they currently don't have a loader for Contentful, so I used the even newer feature of providing a custom loader. I created a new component called CImage
, that included the loader, and used this in place of the base Image
in all places, this component used the following code
import Image from "next/image";
const myLoader = ({ src, width, quality }) => {
return `${src}?w=${width}&q=${quality || 75}`;
};
export default function CImage(props) {
return <Image {...props} loader={myLoader} />;
}
This will load the correct width for the viewport and allow customization of the quality, and if unchanged, use a quality of 75.
I also wanted to use Framer Motion to animate the lightbox when clicking on an image, however Framer only works with native HTML elements, not with React components. Even though I couldn't use it, I did want to try to make it, and created a working example using AnimateSharedLayout
and AnimatePresence
like this
<AnimateSharedLayout type="crossfade">
{pagedata.imagesCollection.items.map((x) => (
<motion.div
onClick={() => setSelectedId(x.url)}
className="overflow-hidden mb-8 rounded-lg"
>
<motion.img
layoutId={x.url}
src={x.url}
width={x.width}
height={x.height}
className="rounded-lg overflow-hidden mb-8 pb-0"
/>
</motion.div>
))}
<AnimatePresence>
{selectedId && (
<Modal selectedId={selectedId} setSelectedId={setSelectedId} />
)}
</AnimatePresence>
</AnimateSharedLayout>
Where the Modal
component handled clicking outside to dismiss and included some more motion
elements to allow for animation
import { motion } from "framer-motion";
import { useRef, useEffect } from "react";
export default function Modal({ selectedId, setSelectedId }) {
const node = useRef();
const handleClickOutside = (e) => {
console.log(e);
if (selectedId) {
if (node.current.contains(e.target)) {
return;
}
setSelectedId(null);
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
});
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="z-10 fixed pt-20 left-0 top-0 w-full h-full overflow-auto bg-black-opaque"
>
<motion.img
layoutId={selectedId}
className="max-w-2/3 max-h-3/4 z-10 m-auto text-center"
src={selectedId}
ref={node}
/>
</motion.div>
);
}
However, in order to also be able to use next/image
I couldn't use this, and instead implemented simple-react-lightbox. Sadly due to the way that images are loaded using next/image
I couldn't just use the simple wrapper provided as the images wouldn't load. Fortunately simple-react-lightbox
allows you to create a lightbox just using props to a component. So using the data from the CMS I could create an object to pass to the component using the following map
const elements = pagedata.imagesCollection.items.map((x) => ({
src: x.url,
}));
Then to open the images I added onClick
to the images to open the lightbox. Simple React Lightbox provides a useLightbox
hook which allows you to open and close the lightbox, and importantly you can open a specified image using its index using
const { openLightbox, closeLightbox } = useLightbox();
openLightbox(2);
to open the 3rd picture (indexed from zero hence 2 is passed).
JavaScript maps allow you to also include the index, so I used the following map to loop over the images and open the correct one on click
{
pagedata.imagesCollection.items.map((x, index) => (
<div
onClick={() => openLightbox(index)}
className="overflow-hidden mb-8 rounded-lg cursor-pointer"
>
<CImage
src={x.url}
width={x.width}
height={x.height}
className="rounded-lg overflow-hidden mb-8 pb-0"
/>
</div>
));
}