🤝 Collaborative & 📸 PhotoGrid Collage Maker - Built on Netlify Primitives ⚡️

Merbin J Anselm - May 12 - - Dev Community

This is a submission for the Netlify Dynamic Site Challenge: Visual Feast, Build with Blobs, Clever Caching.

What I Built

Recently, I embarked on a rejuvenating journey to the picturesque Thekkady region of South India, seeking respite from the sizzling summer heat waves. 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

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, but later switched to AstroJS as the Netlify adapter 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.

GitHub logo anselm94 / netlify-challenge-collagemaker

A Photo Collage Maker for Netlify Dev Challenge

Collaborative PhotoGrid Maker

A collaborative PhotoGrid collage maker written in AstroJS, submitted for the Dev.to Netlify Challenge.

Get Started

  1. Clone this repository
  2. Install dependencies
npm install
  1. Start local server via Netlify CLI
npm run dev

License

MIT License




Demo

Try Demo - photogrider.netlify.app

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

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

Create a photogrid

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

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

Drag and drop

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

Delete image

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

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

Download image

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 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.

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.
};
Enter fullscreen mode Exit fullscreen mode

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

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
    }>;
  }
Enter fullscreen mode Exit fullscreen mode

View full layout metadata
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,
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

Switching layout

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 library, which has out-of-the-box support for Netlify Image CDN and generates responsive image tags with ease.

Actual Unpic Svelte configuration:

<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}"
/>
Enter fullscreen mode Exit fullscreen mode

Rendered HTML code:

<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%;"
/>
Enter fullscreen mode Exit fullscreen mode

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.

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.
);
Enter fullscreen mode Exit fullscreen mode

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.

Astro.response.headers.set("Netlify-Vary", "query=lastmodified|hasconflict");
Enter fullscreen mode Exit fullscreen mode

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.
. .