You have probably used those collage-making apps, either as a native app on your phone or a web app. In this short tutorial, we’ll be looking to achieve the same using some cleverly designed layouts, cloudinary and next.js. We’re going to be using Cloudinary transformations to overlay the images so they match our layout.
The final project demo is available on Codesandbox.
Get the GitHub source code here Github
You need to have a Cloudinary account. If you do not have one you can register for free here. You will also need to have Node.js and NPM or Yarn installed in your development environment. Working knowledge of Javascript and React is required. Knowledge of Node.js and Next.js is a plus but not required.
You can easily create a Next.js project by running the following command in your terminal:
npx create-next-app@latest photo-collage-with-cloudinary
Code language: JavaScript (javascript)
The command scaffolds a new project with the name photo-collage-with-cloudinary
. You can use any appropriate name. For more information on getting started with Next.js and additional options, check out the docs. Change directory to your newly created folder
cd photo-collage-with-cloudinary
Code language: JavaScript (javascript)
You can proceed to open the folder in your favorite code editor.
Assuming you already have a Cloudinary account at this point, head over to the cloudinary console page. On this page, you’ll find your cloud name, api key, and api secret.
Create a new file named .env.local
at the root of your project(photo-collage-with-cloudinary
folder). Paste the following inside .env.local
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Make sure to replace YOUR_CLOUD_NAME
, YOUR_API_KEY
and YOUR_API_SECRET
with the cloud name, api key and api secret values that you got from the cloudinary console page.
We’ve just defined a few environment variables. Environment variables allow us to keep sensitive keys away from our publicly accessible code. Read about environment variables in node.js. Next.js has built-in support for environment variables. Find out more in the next.js environment variables docs.
These are the dependencies we need to install
-
cloudinary -This is the Cloudinary node SDK. It will make API calls easier.
-
formidable – This is a node.js module for parsing form data. It allows for us to handle file uploads
-
canvas – This is a canvas implementation for the server(node.js)
Run the following command to install them
npm install cloudinary formidable canvas
Let’s start by creating some layouts that we can use to create our collages. Create a new folder called lib
at the root of your project. Create a new file called collageLayouts.js
inside this folder. Paste the following inside lib/collageLayouts.js
.
/**
* @typedef {Object} CollageLayout
* @property {number} id
* @property {number} width
* @property {number} height
* @property {() => CollageLayout[]} sections
*/
/**
* @typedef {Object} CollageSection
* @property {number} width
* @property {number} height
* @property {number} x
* @property {number} y
*/
/**
* Pre-defined layouts. You can add more layouts here. Make sure each has a unique id.
*
* @type {CollageLayout[]}
*/
export const layouts = [
{
id: 1,
width: 800,
height: 800,
sections: function () {
return [
{
width: this.width * 0.5,
height: this.height * 0.4,
x: 0,
y: 0,
},
{
width: this.width * 0.5,
height: this.height,
x: this.width * 0.5,
y: 0,
},
{
width: this.width * 0.5,
height: this.height * 0.6,
x: 0,
y: this.height * 0.4,
},
];
},
},
{
id: 2,
width: 800,
height: 400,
sections: function () {
return [
{
width: this.width * 0.5,
height: this.height,
x: 0,
y: 0,
},
{
width: this.width * 0.5,
height: this.height,
x: this.width * 0.5,
y: 0,
},
];
},
},
{
id: 3,
width: 800,
height: 800,
sections: function () {
return [
{
width: this.width * 0.5,
height: this.height * 0.5,
x: 0,
y: 0,
},
{
width: this.width * 0.5,
height: this.height * 0.5,
x: this.width * 0.5,
y: 0,
},
{
width: this.width,
height: this.height * 0.5,
x: 0,
y: this.height * 0.5,
},
];
},
},
{
id: 4,
width: 800,
height: 800,
sections: function () {
return [
{
width: this.width,
height: this.height * 0.5,
x: 0,
y: 0,
},
{
width: this.width * 0.5,
height: this.height * 0.5,
x: 0,
y: this.height * 0.5,
},
{
width: this.width * 0.5,
height: this.height * 0.5,
x: this.width * 0.5,
y: this.height * 0.5,
},
];
},
},
{
id: 5,
width: 800,
height: 600,
sections: function () {
return [
{
width: this.width * 0.4,
height: this.height,
x: 0,
y: 0,
},
{
width: this.width * 0.6,
height: this.height * 0.5,
x: this.width * 0.4,
y: 0,
},
{
width: this.width * 0.6,
height: this.height * 0.5,
x: this.width * 0.4,
y: this.height * 0.5,
},
];
},
},
{
id: 6,
width: 800,
height: 800,
sections: function () {
return [
{
width: this.width,
height: this.height * 0.25,
x: 0,
y: 0,
},
{
width: this.width,
height: this.height * 0.25,
x: 0,
y: this.height * 0.25,
},
{
width: this.width,
height: this.height * 0.25,
x: 0,
y: this.height * 0.5,
},
{
width: this.width,
height: this.height * 0.25,
x: 0,
y: this.height * 0.75,
},
];
},
},
];
Code language: JavaScript (javascript)
At the top we have some jsdoc typedefs. This is just a neat jsdoc feature that lets us define custom types without the need for typescript.
We export an array called layouts. The array contains a bunch of objects. These objects are our layouts. Let’s first understand why we need this data. Every layout is a container of a certain width and height. It also contains a unique id. Each container is divided into smaller containers that we can call sections. The sections are what make up the collage layout. We need to know the width and height of each section and also where to place the section relative to the parent container. We can play with the width
,height
,x
, and y
values to create different layouts.
Create a new file under the lib
folder and name it parse-form.js
. Paste the following inside lib/parse-form.js
.
import { IncomingForm, Files, Fields } from "formidable";
/**
* Parses the incoming form data.
*
* @param {NextApiRequest} req The incoming request object
* @returns {Promise<{fields:Fields;files:Files;}>} The parsed form data
*/
export const parseForm = (req) => {
return new Promise((resolve, reject) => {
const form = new IncomingForm({ keepExtensions: true, multiples: true });
form.parse(req, (error, fields, files) => {
if (error) {
return reject(error);
}
return resolve({ fields, files });
});
});
};
Code language: JavaScript (javascript)
The code inside this file is responsible for parsing incoming form data using the formidable package that we installed earlier. Check out the formidable docs documents for further explanation.
Create a file called cloudinary.js
under the lib
folder. Paste the following inside lib/cloudinary.js
// Import the v2 api and rename it to cloudinary
import { v2 as cloudinary, TransformationOptions } from "cloudinary";
// Initialize the SDK with cloud_name, api_key, and api_secret
cloudinary.config({
cloud_name: process.env.CLOUD_NAME,
api_key: process.env.API_KEY,
api_secret: process.env.API_SECRET,
});
const CLOUDINARY_FOLDER_NAME = "photo-collage/";
/**
* Get cloudinary uploads
* @param {string} folder Folder name
* @returns {Promise}
*/
export const handleGetCloudinaryUploads = (folder = CLOUDINARY_FOLDER_NAME) => {
return cloudinary.api.resources({
type: "upload",
prefix: folder,
resource_type: "image",
});
};
/**
* @typedef {Object} Resource
* @property {string | Buffer} file
* @property {string} publicId
* @property {boolean} inFolder
* @property {string} folder
* @property {TransformationOptions} transformation
*
*/
/**
* Uploads an image to cloudinary and returns the upload result
*
* @param {Resource} resource
*/
export const handleCloudinaryUpload = ({
file,
publicId,
transformation,
folder = CLOUDINARY_FOLDER_NAME,
inFolder = false,
}) => {
return cloudinary.uploader.upload(file, {
// Folder to store image in
folder: inFolder ? folder : null,
// Public id of image.
public_id: publicId,
// Type of resource
resource_type: "auto",
// Transformation to apply to the video
transformation,
});
};
/**
* Deletes resources from cloudinary. Takes in an array of public ids
* @param {string[]} ids
*/
export const handleCloudinaryDelete = (ids) => {
return cloudinary.api.delete_resources(ids, {
resource_type: "image",
});
};
Code language: PHP (php)
The code inside this folder is responsible for communication with Cloudinary via the SDK we installed earlier. At the top of the file, we import the v2
API from Cloudinary and rename it to cloudinary
. You can leave it as v2
, we just did this for readability. We then call the .config
method on the API to initialize it and authenticate our application. We pass to it the cloud_name
, api_key
, and api_secret
. Remember we defined these as environment variables earlier. CLOUDINARY_FOLDER_NAME
defines the folder where we want to store our collage images.
The handleGetCloudinaryUploads
function calls the api.resources
method on the api. This fetches all resources that have been uploaded to a specific folder. Read about this in the admin api docs.
handleCloudinaryUpload
calls the uploader.upload
method. This uploads a file to Cloudinary. It takes in an object that contains the file we want to upload, an optional publicId, a transformation object, whether or not to place the file inside a folder, and an optional folder name. Read more about the upload method in the upload docs.
handleCloudinaryDelete
passes an array of public IDs to the api.delete_resources
method for deletion. Read all about this method in the cloudinary admin api docs.
That’s it for the lib
folder.
Moving on to our API routes. API routes are a core part of Next.js. Read about API routes in Next.js here.
Create a folder called images
inside pages/api
. Create a new file called index.js
inside pages/api/images
. This file will handle http requests made to the /api/images
endpoint.
Paste the following code inside pages/api/images/index.js
.
import { NextApiRequest, NextApiResponse } from "next";
import { createCanvas } from "canvas";
import { parseForm } from "../../../lib/parse-form";
import {
handleCloudinaryDelete,
handleCloudinaryUpload,
handleGetCloudinaryUploads,
} from "../../../lib/cloudinary";
export const config = {
api: {
bodyParser: false,
},
};
/**
*
* @param {NextApiRequest} req
* @param {NextApiResponse} res
*/
export default async function handler(req, res) {
const { method } = req;
switch (method) {
case "GET": {
try {
const result = await handleGetRequest();
return res.status(200).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
case "POST": {
try {
const result = await handlePostRequest(req);
return res.status(201).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
async function handleGetRequest() {
return handleGetCloudinaryUploads();
}
/**
*
* @param {NextApiRequest} req
*/
async function handlePostRequest(req) {
console.log("post received");
// Get the form data using the parseForm function
const data = await parseForm(req);
// Get the layout data
const layout = JSON.parse(data.fields["layout"]);
// The transformation object that will be passed to cloudinary to overlay the different images
const transformation = [];
// Loop through the uploaded images, upload each to cloudinary and populate the transformation array
for (const [key, file] of Object.entries(data.files)) {
// Upload the image to cloudinary
const imageUploadResponse = await handleCloudinaryUpload({
file: file.filepath,
});
// Get the image section data
const section = JSON.parse(data.fields[key]);
// Create a transformation object and append it to the transformation array. The section data contains the x, y, width and height of the image which we need to overlay the image appropriately
transformation.push({
overlay: imageUploadResponse.public_id,
width: section.width,
height: section.height,
x: section.x,
y: section.y,
crop: "fill",
gravity: "north_west",
});
}
// Create a canvas object
const canvas = createCanvas(layout.width, layout.height);
// Create a canvas context
const context = canvas.getContext("2d");
// Fill the canvas with white
context.fillStyle = "#ffffff";
// Fill the canvas
context.fillRect(0, 0, layout.width, layout.height);
// Get the canvas image data
const backgroundImageBuffer = canvas.toBuffer("image/png");
// Upload the background image to cloudinary
const backgroundImageUploadResponse = await handleCloudinaryUpload({
file: `data:image/png;base64,${backgroundImageBuffer.toString("base64")}`,
inFolder: true,
transformation,
});
// Delete the initially uploaded images from cloudinary
await handleCloudinaryDelete(transformation.map((t) => t.overlay));
return backgroundImageUploadResponse;
}
Code language: JavaScript (javascript)
A Next.js API route needs to have a default export that is a function that takes in the incoming request object and the outgoing response object. Read about this in the docs.
At the top, we export a custom config object. The custom configuration lets next.js know that we don’t want to use the default body-parser. Instead, the body is going to be a stream, and that way we can parse it using formidable. See here. Read more about custom config and API middleware in the next.js docs.
Inside our handler
function, we check the incoming HTTP request method. We only want to handle GET and POST methods so we use a switch case statement to check for that and return a 405 – Method Not Allowed response if the request method doesn’t match any of our cases.
handleGetRequest
calls the handleGetCloudinaryUploads
function that we created earlier to get all uploaded resources.
handlePostRequest
takes in the incoming request object. It first passes the request object to the parseForm
function that we created earlier. parseForm
parses the form data. In the form data, we have a layout
field and then fields that contain section data(width, height,x,y) for each image uploaded. The form data also contains each uploaded file. We first get the layout data by parsing the stringified JSON. We have a transformation
array. This is what cloudinary will use to determine where to overlay our images. Read more here.
We loop through the files that have been uploaded. For each file/image, we upload the image to Cloudinary, then create a transformation object that we’ll append to the transformation
array. The transformation object contains the overlay
field, which is the public id, the width
and height
of the section where the image will be placed, the x
and y
coordinates of the section, and then crop
and gravity
. Read about placing layer overlays on images for more information. For the crop
field, we set it to fill
so that the image maintains its aspect ratio. You can change this to your liking. Read about it here. For the gravity
, we set it to north_west
to tell Cloudinary that all x and y values are relative to the top-left corner. In short, the top-left will be the origin(0,0). Read more about it here.
We need a background image where we’re going to overlay our already uploaded images/sections. For this we’re going to be using the canvas package we installed to create a canvas, fill it with the color white and then get the canvas as an image(Buffer data). We then convert that buffer to a base64 string and upload it to Cloudinary. We also pass the transformation array that we defined. At this point, the transformation array will contain a transformation object for each of our images. We then delete the initially uploaded images since they have been added as overlays to the background image and we no longer need them.
You can also place the overlays using the canvas and just upload the final image to Cloudinary(would be cheaper in terms of Cloudinary tokens/storage) but I wanted to do everything using Cloudinary so we can touch on Cloudinary transformations.
Create a new file called [...id].js
under pages/api/images/
folder. Paste the following code inside pages/api/images/[...id].js
.
import { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { handleCloudinaryDelete } from "../../../lib/cloudinary";
/**
* @type {NextApiHandler}
* @param {NextApiRequest} req
* @param {NextApiResponse} res
*/
export default async function handler(req, res) {
let { id } = req.query;
if (!id) {
res.status(400).json({ error: "Missing id" });
return;
}
if (Array.isArray(id)) {
id = id.join("/");
}
switch (req.method) {
case "DELETE": {
try {
const result = await handleDeleteRequest(id);
return res.status(200).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handleDeleteRequest = async (id) => {
const result = await handleCloudinaryDelete([id]);
return result;
};
Code language: JavaScript (javascript)
This file handles requests made to the /api/images/:id
endoint. This is a dynamic API route. Read about it here. The destructured array syntax for the file name is used to match all routes that come after a dynamic route. For example to handle routes such as /api/images/:id/:anotherId/
or /api/images/:id/someAction/
instead of just /api/images/:id
. Read about catching all api routes.
This route only handles DELETE requests. We get the id from the incoming request query and pass that to handleCloudinaryDelete
for deletion.
That’s it for the backend.
Now for the front end.
Replace the contents of styles/globals.css
with the following…
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
:root {
--color-primary: #0070f3;
--color-danger: #ff0000;
}
* {
box-sizing: border-box;
}
img {
object-fit: cover;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.danger {
color: var(--color-danger);
}
.btn {
background-color: var(--color-primary);
border-radius: 2px;
border: none;
color: #fff;
text-transform: uppercase;
padding: 1rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
min-width: 50px;
}
.btn.danger {
color: #ffffff;
background-color: var(--color-danger);
}
.btn:hover:not([disabled]) {
filter: brightness(96%);
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Code language: CSS (css)
Some simple CSS.
Create a folder at the root of your project and name it components
. Create a new file called Layout.jsx
under components
folder. Paste the following inside components/Layout.jsx
.
import Head from "next/head";
import Link from "next/link";
export default function Layout({ children }) {
return (
<div>
<Head>
<title>Photo collage with cloudinary</title>
<meta name="description" content="Photo collage with cloudinary" />
<link rel="icon" href="/favicon.ico" />
</Head>
<nav>
<Link href="/">
<a>Home</a>
</Link>
<Link href="/images">
<a>Images</a>
</Link>
</nav>
<main>{children}</main>
<style jsx>{`
nav {
height: 100px;
background-color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
color: #ffffff;
font-weight: bold;
}
main {
width: 100vw;
min-height: 100vh;
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
We’re going to be wrapping all our pages in this component so that we have a consistent layout without code duplication.
Create a file called CollageLayout.jsx
under components
. Paste the following inside components/CollageLayout.jsx
.
import { useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
/**
* @typedef {Object} Collage
* @property {File} file
* @property {Section} section
*/
/**
* @typedef {Object} Section
* @property {number} width
* @property {number} height
* @property {number} x
* @property {number} y
*/
/**
*
* @param {{layout:import('../lib/collageLayouts').CollageLayout} props
*/
export default function CollageLayout({ layout }) {
const router = useRouter();
/**
* @type [{[key:string]: Collage},(images: {[key:string]: Collage}) => void]
*/
const [images, setImages] = useState({});
const [loading, setLoading] = useState(false);
async function handleFormSubmit(event) {
event.preventDefault();
try {
const formData = new FormData();
formData.append(
"layout",
JSON.stringify({
width: layout.width,
height: layout.height,
})
);
for (const [key, image] of Object.entries(images)) {
formData.append(key, JSON.stringify(image.section));
formData.append(key, image.file);
}
setLoading(true);
const response = await fetch("/api/images", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw data;
}
router.push("/images");
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
return (
<form className="collage-layout-wrapper" onSubmit={handleFormSubmit}>
<div
className="collage-layout"
style={{
position: "relative",
width: layout.width,
height: layout.height,
}}
>
{layout.sections().map((section, index) => (
<div
className="collage-section"
key={`section-${index}`}
style={{
position: "absolute",
width: section.width,
height: section.height,
left: section.x,
top: section.y,
border: "2px solid black",
boxSizing: "border-box",
backgroundColor: "#ffffff",
}}
>
{images[`layout-${layout.id}-image-${index}`] &&
images[`layout-${layout.id}-image-${index}`].file ? (
<div className="image-preview">
<Image
src={URL.createObjectURL(
images[`layout-${layout.id}-image-${index}`].file
)}
alt={`preview image ${index}`}
layout="fill"
></Image>
</div>
) : (
<div className="file-input">
<label htmlFor={`layout-${layout.id}-image-${index}`}>
Select Image
</label>
<input
type="file"
name={`layout-${layout.id}-image-${index}`}
id={`layout-${layout.id}-image-${index}`}
accept="image/*"
hidden
onChange={(event) => {
setImages({
...images,
[event.target.name]: {
file: event.target.files[0],
section,
},
});
}}
disabled={loading}
/>
</div>
)}
</div>
))}
</div>
<button
className="btn"
type="submit"
disabled={
Object.keys(images).length !== layout.sections().length ||
!Object.values(images).every(
(image) => image.file && image.section
) ||
loading
}
>
{loading ? "Uploading ..." : "Upload"}
</button>
<style jsx>{`
form {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px;
background-color: #ececec;
border-radius: 5px;
}
form button {
width: 100%;
}
form div.collage-layout div.collage-section div.image-preview {
height: 100%;
width: 100%;
position: relative;
object-fit: cover;
}
form div.collage-layout div.collage-section div.file-input {
height: 100%;
width: 100%;
}
form div.collage-layout div.collage-section div.file-input label {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
form div.collage-layout div.collage-section div.file-input label:hover {
background-color: #ececec;
cursor: pointer;
}
`}</style>
</form>
);
}
Code language: JavaScript (javascript)
This is where the frontend magic happens. The component takes in a layout. i.e. One of those layouts from the layouts array inside lib/collageLayouts.js
. The component uses the layout data to create a container of the layout width and height,
<div
className="collage-layout"
style={{
position: "relative",
width: layout.width,
height: layout.height,
}}
>
...
</div>
Code language: HTML, XML (xml)
and then the sections data to create different sections inside the container
{layout.sections().map((section, index) => (
<div
className="collage-section"
key={`section-${index}`}
style={{
position: "absolute",
width: section.width,
height: section.height,
left: section.x,
top: section.y,
border: "2px solid black",
boxSizing: "border-box",
backgroundColor: "#ffffff",
}}
></div>))}
Code language: JavaScript (javascript)
These are all inside of a form element. Each different section checks the images
state, if an image hasn’t been chosen for that section, it displays an input element so the user can select an image. If an image has been selected, it shows a preview of that image.
Let’s talk about the images
state. images
will be an object of the following structure
// A typescript interface
interface Images {
// Can have any key of type string and value of type object
[key:string]: {
// Object Has a file key that has a value of type File
file: File;
// Object Has a section key with a value of type object
section: {
// Object has a width key with a value of the number
width: number;
// Object has a height key with a value of the number
height: number;
// Object has a x key with a value of the number
x: number;
// Object has a y key with a value of the number
y: number;
}
}
}
// For example
const images = {
"layout-1-image-0":{
file: new File(),
section:{
width: 800,
height: 800,
x: 0,
y: 400
}
},
"layout-1-image-1":{
file: new File(),
section:{
width: 600,
height: 700,
x: 300,
y: 100
}
}
}
Code language: PHP (php)
With that in mind, let’s look at the handleFormSubmit
. This is triggered when a user clicks on upload. We first create a new form data object.
const formData = new FormData();
Code language: JavaScript (javascript)
We append the stringified layout data to the form data.
formData.append(
"layout",
JSON.stringify({
width: layout.width,
height: layout.height,
})
);
Code language: CSS (css)
Then for every section/image, we append to the formdata the actual image file and also the stringified section data.
for (const [key, image] of Object.entries(images)) {
formData.append(key, JSON.stringify(image.section));
formData.append(key, image.file);
}
Code language: JavaScript (javascript)
We then post the form data to the /api/images
endpoint and navigate to the /images
page on success.
At the top of the component, we also have the use of some React hooks such as useState
. I’m assuming you are familiar with React and that’s why I’m not going into too much detail. You can have a read in the React docs. Read more about useRouter
in the Next.js router docs
Paste the following inside pages/index.jsx
. If you have pages/index.js
instead, you can just paste there or change the extension to .jsx
.
TIP: Change your frontend components/pages to
.jsx
for better intellisense and code completion
import CollageLayout from "../components/CollageLayout";
import Layout from "../components/Layout";
import { layouts } from "../lib/collageLayouts";
export default function Home() {
return (
<Layout>
<div className="wrapper">
<h1>Photo collages with Cloudinary + Next.js</h1>
<p>
Identify the desired layout below, select your images and click on
upload
</p>
<p>You can create more layouts in lib/collageLayouts.js</p>
<div className="collage-layouts">
{layouts.map((layout, index) => {
return (
<CollageLayout
key={`layout-${index}`}
layout={layout}
></CollageLayout>
);
})}
</div>
</div>
<style jsx>{`
div.wrapper {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
div.wrapper div.collage-layouts {
display: flex;
flex-direction: column;
gap: 50px;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
Nothing complicated happening here.
Create a file called images.jsx
under pages/
folder. Paste the following inside pages/images.jsx
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import Layout from "../components/Layout";
export default function Images() {
const [images, setImages] = useState([]);
const [loading, setLoading] = useState(false);
const getImages = useCallback(async function () {
try {
setLoading(true);
const response = await fetch("/api/images", {
method: "GET",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
setImages(data.result.resources);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
getImages();
}, [getImages]);
const handleDownloadResource = async (url) => {
try {
setLoading(true);
console.log(url);
const response = await fetch(url, {});
if (response.ok) {
const blob = await response.blob();
const fileUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = fileUrl;
a.download = `photo-collage.${url.split(".").at(-1)}`;
document.body.appendChild(a);
a.click();
a.remove();
return;
}
throw await response.json();
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
try {
setLoading(true);
const response = await fetch(`/api/images/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
getImages();
} catch (error) {
} finally {
setLoading(false);
}
};
return (
<Layout>
{images.length > 0 ? (
<div className="wrapper">
<div className="images-wrapper">
{images.map((image) => {
return (
<div className="image-wrapper" key={image.public_id}>
<div className="image">
<Image
src={image.secure_url}
width={image.width}
height={image.height}
layout="responsive"
alt={image.secure_url}
></Image>
</div>
<div className="actions">
<button
className="btn"
disabled={loading}
onClick={() => {
handleDownloadResource(image.secure_url);
}}
>
Download
</button>
<button
className="btn danger"
disabled={loading}
onClick={() => {
handleDelete(image.public_id);
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
) : null}
{!loading && images.length === 0 ? (
<div className="no-images">
<b>No Images Yet</b>
<Link href="/">
<a className="btn">Upload some images</a>
</Link>
</div>
) : null}
{loading && images.length === 0 ? (
<div className="loading">
<b>Loading...</b>
</div>
) : null}
<style jsx>{`
div.wrapper {
min-height: 100vh;
background-color: #f4f4f4;
}
div.wrapper div.images-wrapper {
display: flex;
flex-flow: row wrap;
gap: 10px;
padding: 10px;
}
div.wrapper div.images-wrapper div.image-wrapper {
flex: 0 0 400px;
display: flex;
flex-flow: column;
}
div.wrapper div.images-wrapper div.image-wrapper div.image {
background-color: #ffffff;
position: relative;
width: 100%;
}
div.wrapper div.images-wrapper div.image-wrapper div.actions {
background-color: #ffffff;
padding: 10px;
display: flex;
flex-flow: row wrap;
gap: 10px;
}
div.loading,
div.no-images {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
gap: 10px;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
This component uses the React useEffect
hook, to run the memoized getImages
function. Read more about useEffect and useCallback.
getImages
makes a GET request to the /api/images
endpoint to get all uploaded images.
handleDelete
makes a DELETE request to /api/images/:id
to delete the resource/image with the given id.
For the body of the component, we just show the images in a flexbox container along with a delete and download button.
One more thing we need to do. We need to add the Cloudinary domain to our next.js configuration.
Modify next.config.js
and add the following.
module.exports = {
// ...others
images: {
domains: ["res.cloudinary.com"],
},
};
Code language: JavaScript (javascript)
This is to enable Next.js to optimize the images that we’re showing using the Image component. Read more about this here.
And that’s it. You can now run your application!
npm run dev
You can find the full source code on my Github. If you’d like a challenge or some homework, try and figure out how you can add a border to your layouts.