Introduction
Recently, I wrote about how I migrated my blog from Gatsby to Astro in the post titled, Another Migration: From Gatsby to Astro. In that post, I mentioned the migration of the photo gallery page of my website was somewhat more complex than the other parts of the site. As such, I indicated I would write more about it in a separate post where I can go into detail about it.
In this post, I want to talk about the approach I took to the photo gallery migration. The redevelopment of this feature required careful understanding of how Astro approaches image handling and how I could reuse as much of my previous architecture as possible.
But first before diving in, I just want to give a shout out to the wonderful community the Astro maintainers have created. I shared my last post on their Discord server and they asked if it could be added to the Community Resources section of the Gatsby migration page. Of course, I was happy to oblige their request. As of the time of publishing this post, the link should now be visible in the Astro documentation.
The Old Architecture
In two previous posts titled Building a Photo Gallery for a Gatsby Site and Automating Gallery Updates with AWS Lambda, I wrote about the approach I took to developing the photo gallery page for my website. However, the first post showed how the gallery was explicitly tied to Gatsby via the gatsby-source-s3 plugin. During builds, the images would be downloaded from Digital Ocean Spaces and stored in such a way as to be accessible via Gatsby's GraphQL implementation. Additionally, there was a third party React dependency introduced called yet-another-react-lightbox.
In the second post, I wrote about how I updated this system to allow builds to be triggered automatically when adding or removing images from the gallery bucket. The second post is a bit shorter and more straightforward than the first post since it just talks about creating a simple Lambda function responsible for calling a webhook.
As a whole, the old architecture was somewhat simple. During builds, S3 images are downloaded and optimized by Gatsby to be displayed on the page. To update the site when new photos were uploaded, a Lambda function would run that could launch a new Amplify build so those images get pulled in.
Understanding Astro's Approach to Images
Gatsby has a rich plugin in ecosystem which can signficantly extend its capabilities. As previously mentioned, one such plugin is gatsby-source-s3 which allowed me to easily add S3 objects to my Gatsby builds and query them through the Gatsby GraphQL API.
The Astro approach to images is similar to Gatsby's in several ways. When images are added to a site, they can be stored in specific directories and added to components. Astro will optimize local images or remote images. When storing locally, they can be saved in the src/
or public/
folders. Remote images are somewhat different because they can come from a CMS or some other external source. Unfortunately, S3 doesn't appear to be supported as a CMS or have an integration available.
Since there was no existing solution to utilizing S3, I had to come up with my own strategy. Part of that strategy involved recycling as much code as I could from the Gatsby implementation.
Finding the Reusable Pieces
I view migrations like this as a way of finding the strongest and most flexible architectural approach. The pieces which are able to survive the migrations are by definition the most flexible ones. For example, object storage has remained a key component of the gallery from the initial development up until today. While the provider has shifted from Digital Ocean Spaces to S3, the concept remains the same.
One source of reusability was the gallery's React component from the Gatsby code. Not all of the code in the file could be salvaged, but some of the code could be. Specifically, the parts relating to client interactivity and passing state to the Lightbox
component. We'll take a look at the Astro port of the component later on in this post.
Integrating CloudFront
Since Astro didn't provide an easy way of downloading the images, I realized I would have to make the images remotely accessible. When thinking about how I would approach this, I considered simply making the bucket public. However, I thought a better approach would be to put them behind a CloudFront distribution. CloudFront allowed me to restrict access to the bucket and make the bucket contents globally available.
To do this, I followed this documentation page from AWS titled Getting started with a simple CloudFront distribution. Since I already had the S3 bucket set up, I was able to skip to Step 3: Create a CloudFront distribution.
The Code
The code which powers the gallery is split into two main pieces:
- A React component
src/components/PhotoGallery.jsx
. - A page
src/pages/gallery.astro
.
The React Component
Since I had already done all the hard work of finding a React lightbox and creating a React component when I was using Gatsby, I decided to try and reuse as much of that code as possible.
import Lightbox from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/plugins/captions.css";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Slideshow from "yet-another-react-lightbox/plugins/slideshow";
import { useState } from "react";
const PhotoGallery = ({images, slides}) => {
const [index, setIndex] = useState(-1);
return (
<>
<Lightbox
open={index >= 0}
index={index}
close={() => setIndex(-1)}
slides={slides}
plugins={[Captions, Zoom, Fullscreen, Slideshow]}
captions={{ showToggle: true }}
/>
<div className="tile is-ancestor">
<div className="tile is-vertical">
{images.map((imageRow, index) => {
return (
<div className="tile is-parent" key={index}>
{imageRow.map((image, index) => {
if (!image.hasOwnProperty('nodeIndex')) {
throw Error("No image nodeIndex set");
}
const nodeIndex = image.nodeIndex;
return (
<div className="is-4 is-child p-2" key={index}>
<div
className="is-clickable"
onClick={() => setIndex(nodeIndex)}
onKeyDown={() => setIndex(nodeIndex)}
role="presentation"
>
<img className="image" alt={image.title} src={image.url} />
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
</>
);
};
export default PhotoGallery;
Compared to the Gatsby component, the JSX is notably simplified. The client-side code is now separated out into a separate file. Note how the component has two properties: images
and slides
. The images
property is a two dimensional array representing the grid of images to be displayed on the page. The slides
parameter is a restructured version of the image data to match the arguments required by the Lightbox
component.
The Gallery Page
The bulk of the processing for the gallery page takes place in the src/pages/gallery.astro
component. As such, it's the file with the most complex code.
Here's the code for the page:
---
import fs from "fs";
import os from "os";
import path from "path";
import { S3Client, ListObjectsV2Command, GetObjectCommand, type S3ClientConfig } from "@aws-sdk/client-s3";
import fastExif from "fast-exif";
import Page from "../layouts/Page.astro";
import lodash from "lodash";
import type { GallerySlide, ImageNode } from "../components/types";
import PhotoGallery from "../components/PhotoGallery.jsx";
const tempDirectoryPrefix = "com.logarithmicspirals";
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirectoryPrefix));
fs.mkdirSync(path.join(tmpDir, "gallery"));
const Bucket = import.meta.env.S3_GALLERY_BUCKET_NAME;
const config: S3ClientConfig = {
region: import.meta.env.S3_REGION,
credentials: {
accessKeyId: import.meta.env.S3_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.S3_SECRET_ACCESS_KEY
}
};
const client = new S3Client(config);
const listObjectsCommand = new ListObjectsV2Command({
Bucket,
Prefix: "gallery/",
StartAfter: "gallery/"
});
let imageNodes: Array<ImageNode> = new Array();
try {
let isTruncated = true;
let contents = "";
console.log("Your bucket contains the following images:");
while (isTruncated) {
let { Contents, IsTruncated, NextContinuationToken } = await client.send(listObjectsCommand);
if (!Contents) {
Contents = [];
}
const contentsList = (await Promise.all(Contents?.map(async (c) => {
const Key = c.Key ?? '';
const getObjectCommand = new GetObjectCommand({ Bucket, Key });
const object = await client.send(getObjectCommand);
const objectPath = path.join(tmpDir, Key);
const objectBody = (await object.Body?.transformToByteArray());
if (objectBody) {
fs.writeFileSync(objectPath, objectBody);
}
const url = `${import.meta.env.GALLERY_CLOUDFRONT_DOMAIN}/${Key}`;
await fastExif.read(objectPath).then(exifData => {
if (Object.hasOwn(exifData as object, "gps")) {
throw new Error("GPS data found in image");
}
const title = lodash.get(exifData, ["image", "ImageDescription"], null);
const bodyMake = lodash.get(exifData, ["image", "Make"], null);
const bodyModel = lodash.get(exifData, ["image", "Model"], null);
const iso = lodash.get(exifData, ["exif", "ISO"], null);
var lensModel = lodash.get(exifData, ["exif", "LensModel"], null);
var lensMake = lodash.get(exifData, ["exif", "LensMake"], null);
const focalLength = lodash.get(exifData, ["exif", "FocalLength"], null);
if (lensMake !== null) {
lensMake = lensMake.replaceAll("\u0000", "");
}
if (lensModel !== null) {
lensModel = lensModel.replaceAll("\u0000", "");
}
imageNodes.push({
key: Key,
title,
technical: {
bodyMake,
bodyModel,
focalLength,
iso,
lensMake,
lensModel
},
url
})
});
return ` • ${Key}`;
}))).join("\n");
contents += contentsList + "\n";
isTruncated = IsTruncated ? IsTruncated : false;
listObjectsCommand.input.ContinuationToken = NextContinuationToken;
}
console.log(contents);
} catch (err) {
console.error(err);
}
imageNodes = imageNodes.sort((a, b) => b.key.localeCompare(a.key));
const slides: GallerySlide[] = imageNodes.map(image => {
const {bodyMake, bodyModel, lensMake, lensModel} = image.technical;
return {
src: image.url,
title: image.title,
description: `Shot with a ${bodyMake} ${bodyModel} body and ${lensMake} ${lensModel} lens`
}
});
const images: Array<Array<ImageNode>> = [];
images.push([]);
let imageRowIndex = 0;
for (let i = 0; i < imageNodes.length; i ++) {
if ((i + 1) % 3 === 0) {
images.push([]);
imageRowIndex ++;
}
imageNodes[i].nodeIndex = i;
images[imageRowIndex].push(imageNodes[i]);
}
fs.rmSync(tmpDir, { recursive: true });
---
<Page title="Photo Gallery | Logarithmic Spirals" description="Dive into the Logarithmic Spirals gallery for a visual journey through architectural wonders and natural beauty.">
<div class="container">
<div>
<h1 class="title is-1 has-text-centered">Photo Gallery</h1>
<p class="has-text-centered pb-2">
Welcome to my photo gallery page! Here, I am excited to showcase how
I've developed both the vision of my photographs and the function of
my website in tandem. My goal has been to create a seamless
integration of form and function, where each element of the website,
from the layout to the photos, work together harmoniously. As you
explore this gallery, I hope you'll appreciate not only the beauty
of the photos but also the thoughtfulness behind their presentation.
Please note that like the website, this gallery is a work in
progress. I may add or remove photos over time to ensure that I'm
presenting the best examples of my work. So, I invite you to come
back often to see what new additions have been made and to continue
exploring with me.
</p>
<p class="has-text-centered">
My current style of photography is heavily influenced by my love of
architecture and the outdoors. I enjoy capturing the beauty of the
natural world and the built environment, and I'm always looking for
new ways to combine the two. I also enjoy experimenting with
different techniques, such as long exposures, to create unique
images. I hope you enjoy my work!
</p>
</div>
<hr />
<PhotoGallery images={images} slides={slides} client:only="react" />
</div>
</Page>
Let me break it down as a series of steps:
- A temporary directory is created in the host system to store images downloaded from S3.
- An
S3Client
instance is created. - A
ListObjectsV2Command
instance is created. - An array called
imageNodes
is created to store objects containing image data. - The objects in the bucket are iterated over and metadata is extracted from them using the fast-exif package. To extract the metadata, the objects are first downloaded to the temporary directory created in step one.
- The images are sorted based on their filenames.
- The
slides
array is created with objects containing the properties required by yet-another-react-lightbox. - The image nodes are restructured into a two-dimensional array called
images
which will be used to create the image grid on the gallery page. - The temporary directory is removed from the host system.
- The
slides
andimages
arrays are passed to thePhotoGallery
component.
Note the code for step five was written using the "List objects in a bucket" example from the Amazon S3 examples using SDK for JavaScript (v3) as a starting point.
Now, one weird part is the images are remote so we have to combine the CloudFront domain with the image filename to create the URL:
const url = `${import.meta.env.GALLERY_CLOUDFRONT_DOMAIN}/${Key}`;
This URL is then used by the PhotoGallery
component to create the image element on the page.
Now, there is one big drawback to this approach and that is the redownloading of images from S3 while developing in dev mode.
A Closer Look: Custom Types
The source code for the site utilizes TypeScript, so I had to define some custom types. You can see them being referenced in the gallery page's source code above. These type definitions are saved in the file src/components/types.ts
. Here's the custom type definitions:
export type ImageNode = {
key: string,
title: string,
technical: {
bodyMake: string,
bodyModel: string,
focalLength: string,
iso: string,
lensMake: string,
lensModel: string
},
url: string,
nodeIndex?: number
}
export type GallerySlide = {
src: string,
title: string,
description: string
}
Conclusion
Reflecting on the migration of my photo gallery from Gatsby to Astro, I've embarked on a journey of adaptation and innovation within a new framework. Through this process, I've navigated Astro's distinct approach to image handling, leveraging my prior experiences to craft a solution that integrates smoothly with Astro's ecosystem. This transition went beyond merely transferring content; it involved a comprehensive reevaluation and reconstruction of the gallery's architecture to harness Astro's full potential.
As I look back on the work completed, I recognize opportunities for enhancement:
- Optimizing the Lambda Function: By refining the Lambda function to extract and store image metadata as JSON, accessible via CloudFront, I can streamline the management and accessibility of gallery images.
- Improving Image Loading Efficiency: Modifying the gallery page to minimize redundant image downloads will enhance site performance. Tools like the astro-preload package may offer a viable solution for more efficient image handling.
These reflections and planned improvements underscore a pivotal lesson from this migration: the essence of web development lies in its flexibility and the continuous quest for innovation. Embracing new technologies and frameworks not only propels our projects forward but also enriches our skills and perspectives as developers. This migration, challenging yet enlightening, has not only advanced my project but has also set the stage for ongoing enhancement and growth.