🤝 Collaborative & 📸 PhotoGrid Collage Maker - Built on Netlify Primitives ⚡️
Tags: netlifychallenge, devchallenge, webdev, javascript
Published: 2024-05-12 16:51:44

_This is a submission for the [Netlify Dynamic Site Challenge](https://dev.to/challenges/netlify): Visual Feast, Build with Blobs, Clever Caching._

## What I Built

Recently, I embarked on a rejuvenating journey to the picturesque [Thekkady](https://en.wikipedia.org/wiki/Thekkady) region of South India, seeking respite from [the sizzling summer heat waves](https://www.newindianexpress.com/explainers/2024/May/12/explained-why-is-peninsular-india-reeling-under-heat-wave-and-water-crisis). Little did I know that this trip would inspire a unique project for the Netlify Dev Challenge. Upon my return, I found myself tasked with creating a collage of the captivating photos I had captured during my adventure. Instead of settling for the usual approach of using another online 'collage maker', I decided to take up the challenge and craft something useful – the **Collaborative Photo Collage Maker**. Yes, I know, this is something bit too much for a trivial task. But it's fun to do so ;).

![Collage created using PhotoGrid Collage Maker - Trip to Thekkady](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/37uhlk39i1vx3j128b30.png)

I set out to create the collaborative PhotoGrid Collage Maker, a dynamic web application that allows users to visually build **photo collages** together with friends, even asynchronously. The project is a perfect fit for the Netlify Dynamic Site Challenge, showcasing the power of Netlify's platform primitives, including **Netlify Blobs**, **Netlify Image CDN**, and **Netlify Cache-Control**. At its core, the PhotoGrid Collage Maker is a blank grid where users can select from various **grid layouts**, ranging from **2 to 8 images**. With a simple **drag-and-drop interface**, users can **upload** their photos and arrange them within the chosen layout. The beauty of this application lies in its **collaborative** nature – users can share a **unique link** with friends, allowing them to contribute to the collage one image at a time.

But that's not all! The app also boasts intelligent **conflict resolution**, ensuring that even if multiple users make changes simultaneously, their edits are seamlessly merged. Thanks to Netlify Blobs' **'strong' consistency mode**, the app keeps track of any conflicts and notifies users if reconciliation is required. Once the masterpiece is complete, users can effortlessly **download** their collage as a high-quality PNG image with a single click. And let's not forget about **performance** – the Netlify Image CDN optimizes all uploaded images, ensuring they're served at the **perfect resolution**, while Netlify's granular Cache Control (using `Netlify-CDN-Cache-Control` & `Netlify-Vary` headers) keeps the site lightning-fast.

I wrote the whole application in AstroJS with Svelte Islands. Though I initially started in SvelteKit but was hit with this [infamous issue](https://answers.netlify.com/t/netlify-blobs-accessing-from-function-inside-sveltekit-app-requiring-explicit-token-siteid-deployid/107200/16), but later switched to AstroJS as the [Netlify adapter](https://docs.astro.build/en/guides/integrations-guide/netlify/) already supports Netlify Functions v2. I know, switching from SvelteKit used as an SPA/SSR framework to AstroJS as an MPA framework does not make sense at first sight, but I realised once after this implementation, that was the right choice to demonstrate the platform primitives for the challenge. And for the effort they both have a lot of similarities, and AstroJS can support multiple UI frameworks as islands, the porting was a lot easier.

{% embed https://github.com/anselm94/netlify-challenge-collagemaker %}

## Demo

{% cta https://photogrider.netlify.app/ %} Try Demo - photogrider.netlify.app {% endcta %}

Let me walkthrough you for a short demo of user-facing functionalities and how Netlify Platform Primitives enables them with its technical capabilities using GIFs.

a. Open the website. You'll be greeted with a homepage

![Home page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8dx2oka51vy5juh2uoip.png)

b. Then click on 'Create a Photogrid' action to start creating a photogrid

![Create a photogrid](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nqzdcb0c1or3dxlpbep9.gif)

c. You are presented with an empty grid with drag-drop zones for 2 images. For adding more images, select a layout from the list to add even more upto 8 images in different layouts

![Switch layouts](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nib9ni9jxycuixljtuzr.gif)

d. You can drag and drop images into these drag-drop zones to upload them

![Drag and drop](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uya8x5v74q8plvp0ugdc.gif)

e. Accidentally selected the wrong image? Fear not, just delete it

![Delete image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qsnccn7ucrmcyq2r7me9.gif)

f. Next to the fun part, you can share your photogrid with your friends. What if your friend made an edit while you just edited? Sorry your changes will be lost, but no worries, you can always try again at the now synced version

![Collaboration](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oy1qbtprmx02obt1gnxm.gif)

g. Happy with your edit? You can now download your photogrid collage as a PNG image

![Download image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/brph9q1vkgngiua8lxbk.gif)

## Platform Primitives

Hope you enjoyed the demo. Now, onto the technical details. As said in the introduction, this submission focuses on all 3 prompts - Visual Feast, Build with Blobs, Clever Caching. Let me walk you through how I leveraged the Netlify Platform Primitives.

### 1. Netlify Blobs

#### Usecase 1: Data structure - Metadata + Images

At the heart of this application lies Netlify Blobs storage solution, to persist both the metadata and images associated with each photogrid. For every photogrid created, a metadata object and up to seven image entries are stored in Netlify Blobs. The keys are [K-sortable UIDs](https://www.npmjs.com/package/xksuid) enabling UIDs to be sorted in chronological order.

| key                | value          |
| ------------------ | -------------- |
| `{ksuid}/metadata` | {metadata}     |
| `{ksuid}/image-0`  | {image-file-0} |
| `ksuid}/image-1`   | {image-file-1} |
| ...                | ...            |

#### Usecase 2: Wiki-style collaboration

The metadata object contains essential details such as the grid ID, last modified timestamp, layout, and an array of image IDs.

```typescript
type GridMetadata = {
  id: string; // grid id - ksuid based
  lastModified: number; // to enable collaboration
  layout:
    | "grid-2"
    | "grid-3"
    | "grid-4"
    | "grid-5"
    | "grid-6"
    | "grid-7"
    | "grid-8";
  images: Array<string | null>; // array containing image-ids. Initially contains 8 null values.
};
```

Netlify Blobs' 'strong' consistency mode plays a crucial role in enabling real-time collaboration. After every action, the changes are committed back to the Netlify Blob storage, along with the `lastModified` timestamp. For every client-side action, the last known `lastModified` timestamp is sent from the web client and compared with the entry in the storage. If the client's `lastModified` is older, the operation is discarded; otherwise, the entry is updated, ensuring seamless synchronization across multiple users.

### 2. Netlify Image CDN

The photogrid is a CSS Image Grid. For each of the images in the grid, the width & height of the image differs between the selected layout. The absolute image size is required along with how the image is spanned across in the grid. Below is how the layout info looks like

```ts
type LAYOUTS = Record<
  string, // grid-2, grid-3, etc.
  {
    cells: Array<{
      width: number; // width of image in the cell in px
      height: number; // height of image in the cell in px
      colspan: number; // css grid colspan of the cell in the grid
      rowspan: number; // css grid rowspan of the cell in the grid
    }>;
  }
```

{% collapsible View full layout metadata %}
```ts
export const LAYOUTS: Record<
  string,
  {
    cells: Array<{
      width: number;
      height: number;
      colspan: number;
      rowspan: number;
    }>;
  }
> = {
  "grid-2": {
    cells: [
      {
        width: 350,
        height: 700,
        colspan: 4,
        rowspan: 8,
      },
      {
        width: 350,
        height: 700,
        colspan: 4,
        rowspan: 8,
      },
    ],
  },
  "grid-3": {
    cells: [
      {
        width: 700,
        height: 350,
        colspan: 8,
        rowspan: 4,
      },
      {
        width: 350,
        height: 700,
        colspan: 4,
        rowspan: 8,
      },
      {
        width: 350,
        height: 700,
        colspan: 4,
        rowspan: 8,
      },
    ],
  },
  "grid-4": {
    cells: [
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
    ],
  },
  "grid-5": {
    cells: [
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
    ],
  },
  "grid-6": {
    cells: [
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
    ],
  },
  "grid-7": {
    cells: [
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
      {
        width: 350,
        height: 350,
        colspan: 4,
        rowspan: 4,
      },
      {
        width: 175,
        height: 350,
        colspan: 2,
        rowspan: 4,
      },
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
      {
        width: 350,
        height: 175,
        colspan: 4,
        rowspan: 2,
      },
    ],
  },
  "grid-8": {
    cells: [
      {
        width: 550,
        height: 550,
        colspan: 6,
        rowspan: 6,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
      {
        width: 175,
        height: 175,
        colspan: 2,
        rowspan: 2,
      },
    ],
  },
};
```
{% endcollapsible %}

![Switching layout](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8vp7x89mbqbpu4ofjawl.gif)

To leverage the power of the Netlify Image CDN to deliver optimized and responsive images tailored to each user's device and selected layout, the application utilizes the [unpic](https://unpic.pics/) library, which has [out-of-the-box support for Netlify Image CDN](https://unpic.pics/lib/#supported-cdn-apis) and generates [responsive image tags](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images) with ease.

*Actual Unpic Svelte configuration:*

```tsx
<Image
  class="h-full w-full"
  src={images[i] ?? ""} // parameter - image url -> /.netlify/images?url=/{gridId}/image/{imageId}
  priority={true} // should be loaded with high priority and interactive on load
  layout="constrained" // parameter - layout
  width={cell.width} // parameter - max width
  height={cell.height} // parameter - max height
  alt="Photogrid image - {i}"
/>
```

*Rendered HTML code:*

```html
<img
  class="h-full w-full"
  src="/.netlify/images?w=700&h=350&fit=cover&url=/{gridId}/image/{imageId}"
  priority="true"
  layout="constrained"
  alt="Photogrid image - 2"
  loading="eager"
  fetchpriority="high"
  srcset="
    /.netlify/images?w=640&h=320&fit=cover&url=/{gridId}/image/{imageId}   640w,
    /.netlify/images?w=700&h=350&fit=cover&url=/{gridId}/image/{imageId}   700w,
    /.netlify/images?w=750&h=375&fit=cover&url=/{gridId}/image/{imageId}   750w,
    /.netlify/images?w=828&h=414&fit=cover&url=/{gridId}/image/{imageId}   828w,
    /.netlify/images?w=960&h=480&fit=cover&url=/{gridId}/image/{imageId}   960w,
    /.netlify/images?w=1080&h=540&fit=cover&url=/{gridId}/image/{imageId} 1080w,
    /.netlify/images?w=1280&h=640&fit=cover&url=/{gridId}/image/{imageId} 1280w,
    /.netlify/images?w=1400&h=700&fit=cover&url=/{gridId}/image/{imageId} 1400w
  "
  sizes="(min-width: 700px) 700px, 100vw"
  style="object-fit: cover; max-width: 700px; max-height: 350px; aspect-ratio: 2 / 1; width: 100%;"
/>
```

This approach ensures that each image in the grid is rendered at the appropriate size and resolution, optimizing performance and providing a seamless visual experience for users across various devices and screen sizes.

### 3. Netlify Cache Control

To further enhance performance, I implemented Netlify's granular Cache Control. The application has 1 GET API endpoints serving the images by reading from the Netlify Blob and 2 server-side rendered pages (home page and dynamic page for each photogrid), which are cached for 24 hours on the CDN, improving load times and reducing server load.

```typescript
Astro.response.headers.set(
  "Netlify-CDN-Cache-Control",
  "public, max-age=0, s-maxage=86400, must-revalidate" // cache for 24 hours in the cdn, but not in the browser.
);
```

However, there is a catch. Since the photogrid image is a server-side rendered page that changes with every update (i.e. on uploading an image, deleting an image or selecting a layout), a more granular approach is required. This is where Netlify's granular cache control comes into play. The cache objects are key-values cached in the edge networks. For every action, the URL includes query parameters `?lastmodified=${lastModified}&hasconflict={true|false}`. By utilizing the `Netlify-Vary` header to instruct the CDN to vary the cache object key based on these query parameters, the application ensures that the CDN cache is used to its fullest potential while still serving the most up-to-date content.

```typescript
Astro.response.headers.set("Netlify-Vary", "query=lastmodified|hasconflict");
```

### Additional Netlify Platform Primitives

In addition to the core primitives mentioned above, the PhotoGrid Collage Maker leverages several other Netlify offerings:

- **Netlify Functions**: The project is built using the AstroJS framework, which compiles the application to Netlify Functions, ensuring seamless deployment and serverless execution.
- **Netlify CLI**: The Netlify CLI simplified the entire development workflow, enabling local testing and integration of Netlify Blobs and the Netlify Image CDN, providing an exceptional developer experience.
- **Netlify Site Deploys**: The deployment process is streamlined with a simple `git push` command, leveraging Netlify's seamless CI/CD workflow.