The Jamstack Conference 2021, is right around the corner. Those who wish to attend the virtual conference can generate an amazing-looking badge on the website. I thought that this was super cool and wanted to make an attempt at it using Cloudinary’s chained transformations and Next.js.
The final project can be viewed on Codesandbox.
You can find the full source code on my Github repository.
The deployed application can also be tested Here
For this tutorial, working knowledge of Javascript, HTML, and CSS is required. You’ll also need to be familiar with the basics of React.js and Node.js. With that, you should be able to follow along smoothly. Before anything, ensure that you have Node.js installed in your development environment. The official documentation has an amazing guide on how to get up and running. You can also check out Node version manager, which could be handy for upgrading to new versions and switching between different versions. We’ll also need API credentials from cloudinary. These will allow us to identify ourselves and communicate with the API.
If you’re not familiar with Cloudinary, it’s a service that provides developers with a number of solutions that allow them to upload/store media, transform it and/or optimize it. It’s super convenient, easy to use, and cheap as well, compared to other solutions. The best thing is that you can get started immediately with a free developer account and scale up as your needs grow. For development and testing purposes, it’s very hard to exceed the free usage limits. You should, however, be careful with how you use the API.
Head over to Cloudinary, create an account if you do not have one, and sign in to your account. Once inside, navigate to the dashboard page. Here you will find your API credentials. We’re not going to use them now, but we’ll do so in the next section. Take note of the Cloud name
, API Key
, and API Secret
.
Here’s what we’re trying to implement. We have a pre-built tag frame. This is just a transparent image of the actual tag. We’ll then add two image overlays on the pre-built frame and finally add the user’s name as an overlay as well. The overlays will be applied using Cloudinary’s chained transformations. We could upload the frame + two images on every operation but this would just take up a lot of space on cloudinary. Instead, when the application is first to run, it will check if the tag frame exists on cloudinary and if not, will upload it. This way, we only have to upload the two images without the frame image.
Next.js is our framework of choice for this tutorial. Let’s create a new project. It’s easy to scaffold a project using the CLI app create-next-app
. Open your terminal/command line in your desired project folder and run the following command.
npx create-next-app virtual-event-tag
This creates a new project named virtual-event-tag
and installs the dependencies. You can explore more installation options in the Next.js documentation. You can then switch the directory into the newly created project.
cd virtual-event-tag
Open the project in your favorite code editor and proceed to the next section.
Let’s first install the Cloudinary SDK for Node.js. Run the following command in your terminal.
npm install cloudinary
Create a new file called .env.local
at the root of your project. We’ll be defining our API keys as environment variables in this file. Next.js has built-in support for environment variables. Read more about it in the docs. Paste the following inside .env.local
.
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Replace YOUR_CLOUD_NAME
YOUR_API_KEY
and YOUR_API_SECRET
with the appropriate values that we got from the cloudinary-api-credentials section.
Create a new folder at the root of your project and name it lib
. This folder will hold the shared code. Inside this folder, create a new file called constants.js
. This fill will hold a few constant values. Paste the following inside.
// lib/constants.js
/**
* Name of the folder where our images will be stored
*/
export const CLOUDINARY_FOLDER_NAME = "virtual-event-tags/";
/**
* Name of the badge image/frame
*/
export const BADGE_FRAME_NAME = "badge-bg";
Code language: JavaScript (javascript)
CLOUDINARY_FOLDER_NAME
is the name of the folder where we’ll be storing all our resources on cloudinary. BADGE_FRAME_NAME
is the name of the virtual tag’s frame background/image.
Inside the lib
folder, create a new file called cloudinary.js
. This is where all of our cloudinary code will reside. Paste the following inside.
// lib/cloudinary.js
// Import the v2 api and rename it to cloudinary
import { v2 as cloudinary, TransformationOptions } from "cloudinary";
import { CLOUDINARY_FOLDER_NAME } from "./constants";
// 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,
});
/**
* Gets a resource from cloudinary using its public id
*
* @param {string} publicId The public id of the image
*/
export const handleGetCloudinaryResource = (publicId) => {
return cloudinary.api.resource(`${CLOUDINARY_FOLDER_NAME}${publicId}`, {
resource_type: "image",
type: "upload",
});
};
/**
* Applies chained transformations to an image and returns the url
*
* @param {string} publicId The public id of the image
* @param {TransformationOptions} transformation The transformation options to run on the image
*/
export const handleGetTransformImageUrl = (publicId, transformation) => {
return cloudinary.url(publicId, { transformation });
};
/**
* Uploads an image to cloudinary and returns the upload result
*
* @param {{path: string; transformation?:TransformationOptions,publicId?: string }} resource
*/
export const handleCloudinaryUpload = (resource) => {
return cloudinary.uploader.upload(resource.path, {
// Folder to store the image in
folder: CLOUDINARY_FOLDER_NAME,
// Public id of the image.
public_id: resource.publicId,
// Type of resource
resource_type: "auto",
// Transformation to apply to the video
transformation: resource.transformation,
});
};
Code language: JavaScript (javascript)
I have commented on most of the important parts. Let’s just go through it briefly. We first import the v2 API from the SDK and then rename it cloudinary for readability and consistency. We also import CLOUDINARY_FOLDER_NAME
from lib/constants.js
. Next, we initialize the SDK by calling the config
method on the SDK and passing cloud_name
, api_key
, and api_secret
. Here we use the environment variables that we defined earlier.
handleGetCloudinaryResource
takes in a public ID, then uses the cloudinary Admin API to get the resource with that public id.
handleGetTransformImageUrl
takes in a public ID, and an array of transformations to run on the resource with that public ID. It then chains the transformations onto the resource and returns the resulting url. Read more about this and chained transformations here.
handleCloudinaryUpload
takes in an object containing path
,optional transformation
and optional publicId
. It then calls the uploader.upload
method on the SDK to upload a resource. Read about upload options here.
Let’s move on.
We’re going to be using Next.js API routes to get the images and user’s names from the frontend. API routes in Next.js are really handy and eliminate the need for a separate backend application. Read about them in the official documentation.
We’re also going to be using a package known as formidable to parse the incoming form data. Let’s install that. Run the following in your terminal.
npm install formidable
We’re now ready. Create a folder called images
under the pages/api
folder. Proceed to create two files inside pages/api/images
, one named index.js
and the other [id].js
. The former will handle calls made to the api/images
endpoint and the latter will handle calls to the api/images/:id
endpoint. This is all standard and covered in the docs.
Paste the following inside pages/api/images/index.js
.
// pages/api/images/index.js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { IncomingForm } from "formidable";
import { NextApiRequest, NextApiResponse } from "next";
import {
handleCloudinaryUpload,
handleGetTransformImageUrl,
} from "../../../lib/cloudinary";
import {
BADGE_FRAME_NAME,
CLOUDINARY_FOLDER_NAME,
} from "../../../lib/constants";
// Custom config for our API route
export const config = {
api: {
bodyParser: false,
},
};
/**
* The handler function for the API route. Takes in an incoming request and outgoing response.
*
* @param {NextApiRequest} req The incoming request object
* @param {NextApiResponse} res The outgoing response object
*/
export default async function handler(req, res) {
switch (req.method) {
case "POST": {
try {
const result = await handlePostRequest(req);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
/**
* Handles the POST request to the API route.
*
* @param {NextApiRequest} req The incoming request object
*/
const handlePostRequest = async (req) => {
// Get the form data using the parseForm function
const data = await parseForm(req);
// Get the name of the user from the incoming form data
const name = data.fields.name;
// Get the how it started image file from the incoming form data
const thenImage = data.files.then;
// Get the how it's going image file from the incoming form data
const nowImage = data.files.now;
// Upload the how it started image to Cloudinary
const thenImageUploadResult = await handleCloudinaryUpload({
path: thenImage.path,
});
// Upload the how it's going image to Cloudinary
const nowImageUploadResult = await handleCloudinaryUpload({
path: nowImage.path,
});
// Use Cloudinary to overlay the two images over the badge frame using chained transformations
const url = handleGetTransformImageUrl(
// The badge frame image
`${CLOUDINARY_FOLDER_NAME}${BADGE_FRAME_NAME}`,
[
{
// Then image
overlay: thenImageUploadResult.public_id.replace(/\//g, ":"),
width: 110,
height: 110,
crop: "scale",
gravity: "north_west",
x: 56,
y: 478,
},
{
// Now image
overlay: nowImageUploadResult.public_id.replace(/\//g, ":"),
width: 150,
height: 150,
crop: "scale",
gravity: "north_west",
x: 220,
y: 405,
},
{
// Name layer
overlay: {
font_family: "Arial",
font_size: 36,
font_weight: "bold",
stroke: "stroke",
letter_spacing: 2,
text: name,
},
border: "5px_solid_black",
color: "white",
width: 333,
crop: "fit",
gravity: "north_west",
x: 50,
y: 650,
},
]
);
return url;
};
/**
* Parses the incoming form data.
*
* @param {NextApiRequest} req The incoming request object
*/
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)
Let’s go over that. At the top after our imports, we export a custom config file. This file instructs Next.js not to use the default body parser for incoming requests. We’ll handle this on our own using formidable. Read more about custom config in the docs.
We then have our default handler function that will handle the incoming request. We use a switch statement so that we can allow only POST requests and return a status code 405 – Method not allowed for the rest.
parseForm
takes in the incoming HTTP request and parses it to get the form data. You can read about the options used in the Formidable docs.
handlePostRequest
handles the Post request that is received. It first passes the incoming request to parseForm
to get the form data. We then get the user’s name and how it started/how it’s going images from the parsed form data. We proceed to upload the two images. After the upload, we use the handleGetTransformImageUrl
function we created earlier to chain a few transformations to our tag frame image. We already know the public id of the tag frame image, so we pass that. Next is the transformations, this is the complicated part. We need to place the two images and the name text as overlays over the tag frame image. For this to work out perfectly we need to know exactly where to place the images. You might be wondering how I came up with the values for x,y coordinates, and the width and height. To get these values, there’s no easy way really. You just have to know where to place them. If you designed the tag frame image yourself, this is easy. You just have to note down the coordinates when designing the frame image. In our case, we used a pre-built tag frame image. You could use resources online to determine where the placeholder boxes are. One other way and this is what I used, is to use the Cloudinary editor. I signed into my cloudinary dashboard, uploaded the tag image and a sample image, then used the edit feature on the tag image to place the sample images. From that I just took the values and applied them in the code. Now as long as I use the same tag image, the overlays will always be relative to that tag’s width and height.
One thing to note is the gravity
option. When placing overlays using cloudinary, the origin(0,0) is at the center of the image. There’s no problem using this but I’m more accustomed to the traditional origin where it’s placed at the top left corner of the parent. So to move the origin(0,0) to the top left corner we set the gravity
to north_west
. The rest of the options are straightforward. Read about them here. You can also have a read of this blog post. A few more things to note. You’ll notice that when we pass the overlay for the two images, we’re using regex to replace all /
characters with :
. This is important for the chained transformations to work effectively. Second, for the name overlay, we’re giving it a width and also a crop option. This is to allow the text to wrap if it’s too big.
Finally, we return the url that already has the chained transformations. Moving on, let’s create the other endpoint. This endpoint will mainly handle getting the tag frame image if it exists and uploading it if it does not exist. Paste the following code inside pages/api/images/[id].js
// pages/api/images/[id].js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import {
handleCloudinaryUpload,
handleGetCloudinaryResource,
} from "../../../lib/cloudinary";
/**
* The handler function for the API route. Takes in an incoming request and outgoing response.
*
* @param {NextApiRequest} req The incoming request object
* @param {NextApiResponse} res The outgoing response object
*/
export default async function handler(req, res) {
const { id } = req.query;
switch (req.method) {
case "GET": {
try {
if (!id) {
throw "id param is required";
}
const result = await handleGetRequest(id);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res
.status(error?.error?.http_code ?? 400)
.json({ message: "Error", error });
}
}
case "POST": {
try {
if (!id) {
throw "id param is required";
}
const result = await handlePostRequest(id);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res
.status(error?.error?.http_code ?? 400)
.json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
/**
* Handles the GET request to the API route.
*
* @param {string} id The public id of the image to retrieve
* @returns
*/
const handleGetRequest = (id) => {
return handleGetCloudinaryResource(id);
};
/**
* Handles the POST request to the API route.
*
* @param {string} id The public id that will be given to the image
* @returns
*/
const handlePostRequest = (id) => {
return handleCloudinaryUpload({
path: "public/images/badge-bg.png",
publicId: id,
});
};
Code language: PHP (php)
handleGetRequest
gets the resource using the id passed and returns the image to the frontend. If the request fails, the frontend will check if it’s a 404 – Not Found error and make a POST request to the same endpoint to upload the missing resource.
handlePostRequest
handles the post request. It will upload the tag frame image to cloudinary so that we don’t have to upload it on every request. For this case, we’ve passed a static string pointing to a file that we’ve saved in the public/images
folder. You can download this file from here. Also note that here we’re passing a public id that cloudinary will use instead of letting it assign one for us automatically.
That’s it for the backend.
We’ll have two pages for the frontend, one to customize and save the tag and another to view the customized tag and download it. Paste the following inside pages/index.js
.
import { useRouter } from "next/router";
import Head from "next/head";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { BADGE_FRAME_NAME } from "../lib/constants";
import Link from "next/link";
export default function Home() {
const router = useRouter();
/**
* State to hold the how it started image
* @type {[File,Function]}
*/
const [thenImage, setThenImage] = useState(null);
/**
* State to hold the how it's going image
* @type {[File,Function]}
*/
const [nowImage, setNowImage] = useState(null);
/**
* State to hold the name of the user
* @type {[string,Function]}
*/
const [name, setName] = useState("");
/**
* Loading state
* @type {[boolean,Function]}
*/
const [loading, setLoading] = useState(false);
/**
* State to hold the badge frame url
* @type {[string,Function]}
*/
const [badgeFrameUrl, setBadgeFrameUrl] = useState("");
/**
* State to hold the generated tag url
* @type {[string,Function]}
*/
const [tagUrl, setTagUrl] = useState("");
const checkBadgeFrameExists = useCallback(async () => {
setLoading(true);
try {
// Check if the tag url exists in local storage
const url = window.localStorage.getItem("tagUrl");
// If it does, update the tag url state
if (url) {
setTagUrl(url);
}
// Make GET request to the /api/images endpoint to check if the badge frame exists
const response = await fetch(`/api/images/${BADGE_FRAME_NAME}`, {
method: "GET",
});
const data = await response.json();
// Check if the response is a failure
if (!response.ok) {
// If the status is 404, the badge frame doesn't exist
if (response.status === 404) {
// Make a POST request to the /api/images endpoint to create the badge frame
const uploadResponse = await fetch(
`/api/images/${BADGE_FRAME_NAME}`,
{
method: "POST",
}
);
const uploadResponseData = await uploadResponse.json();
if (!response.ok) {
throw uploadResponseData;
}
// Update the badge frame url state
setBadgeFrameUrl(uploadResponseData.result.secure_url);
} else {
throw data;
}
}
// Update the badge frame url state
setBadgeFrameUrl(data.result.secure_url);
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkBadgeFrameExists();
}, [checkBadgeFrameExists]);
const handleFormSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
// Get the form data
const formData = new FormData(e.target);
// Post the form data to the /api/images endpoint
const response = await fetch("/api/images", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw data;
}
// Save the tag url to local storage
window.localStorage.setItem("tagUrl", data.result);
// Navigate to the tag page
router.push("/tag");
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div className="wrapper">
<Head>
<title>Create virtual event tag</title>
<meta name="description" content="Create virtual event tag" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="badge-wrapper">
<Image
src="/images/badge-bg.png"
alt="Badge"
layout="fixed"
width={433}
height={909}
></Image>
<div className="then">
{thenImage && (
<Image
src={URL.createObjectURL(thenImage)}
alt="Then Image"
layout="fill"
></Image>
)}
</div>
<div className="now">
{nowImage && (
<Image
src={URL.createObjectURL(nowImage)}
alt="Now Image"
layout="fill"
></Image>
)}
</div>
<p className="name">{name}</p>
</div>
<form onSubmit={handleFormSubmit}>
<p className="heading">Customize your virtual tag</p>
<div className="input-wrapper">
<label htmlFor="name">Full Name</label>
<input
type="text"
placeholder="Enter your full name"
name="name"
id="name"
required
autoComplete="name"
disabled={loading}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="file-input-wrapper">
<div className="input-wrapper">
<label htmlFor="then">
<div className="upload-img then-img">
<Image
src="/images/btn-image-upload.svg"
alt="image-upload"
layout="fixed"
width={50}
height={50}
></Image>
</div>
<p>How it started</p>
</label>
<input
type="file"
name="then"
id="then"
required
multiple={false}
accept=".png, .jpg, .jpeg"
disabled={loading}
onChange={(e) => {
const file = e.target.files[0];
setThenImage(file);
}}
/>
</div>
<div className="input-wrapper">
<label htmlFor="now">
<div className="upload-img now-img">
<Image
src="/images/btn-image-upload.svg"
alt="image-upload"
layout="fixed"
width={50}
height={50}
></Image>
</div>
<p>How it's going</p>
</label>
<input
type="file"
name="now"
id="now"
required
multiple={false}
accept=".png, .jpg, .jpeg"
disabled={loading}
onChange={(e) => {
const file = e.target.files[0];
setNowImage(file);
}}
/>
</div>
</div>
<p className="instructions">
We would love to see the journey you have been through. Select two
photos, one for how it started and another for how it is going.
Square photos less than 2MB work best.
</p>
<button
type="submit"
disabled={
!badgeFrameUrl || !thenImage || !nowImage || !name || loading
}
>
Laminate Tag
</button>
{tagUrl && (
<Link href="/tag" passHref>
<button>View Your Existing Tag</button>
</Link>
)}
</form>
</main>
<style jsx>{`
div.wrapper {
height: 100vh;
}
main {
display: flex;
flex-flow: row wrap;
justify-content: center;
gap: 50px;
align-items: center;
min-height: 100%;
width: 100%;
background-color: var(--background-color, #132e74);
}
main div.badge-wrapper {
position: relative;
}
main div.badge-wrapper div.then {
position: absolute;
width: 110px;
height: 110px;
top: 478px;
left: 56px;
}
main div.badge-wrapper div.now {
position: absolute;
width: 148px;
height: 148px;
top: 405px;
left: 220px;
}
main div.badge-wrapper p.name {
font-family: "Alata", sans-serif;
width: 80%;
position: absolute;
top: 600px;
left: 50px;
font-size: 2.6em;
font-weight: bold;
line-height: 1.1em;
color: #ffffff;
-webkit-text-stroke: 1px black;
text-shadow: -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000,
3px 5px 0 #000;
}
main form {
flex: 1 0 100%;
background-color: var(--primary-color, #ffee00);
max-width: 450px;
min-height: 650px;
height: 70%;
padding: 20px;
border-radius: 20px;
box-shadow: 8px 8px 0px 0px rgb(0 0 0);
display: flex;
flex-flow: column nowrap;
justify-content: center;
}
main form p.heading {
font-family: "Alata", sans-serif;
font-size: 2em;
font-weight: 900;
-webkit-text-stroke: 1px white;
}
main form > div.input-wrapper {
display: flex;
flex-flow: column nowrap;
}
main form > div.input-wrapper label {
font-weight: 800;
font-size: 1.5em;
letter-spacing: 0.5px;
margin-bottom: 5px;
font-family: "Amatic SC", cursive;
}
main form > div.input-wrapper input {
font-family: "Alata", sans-serif;
height: 50px;
padding: 10px;
border-radius: 5px;
background-color: #ffffff;
border: 2px solid #000000;
font-size: 1.2em;
font-weight: bold;
box-shadow: 5px 5px 0px 0px rgb(0 0 0);
}
main form > div.file-input-wrapper {
display: flex;
flex-flow: row nowrap;
margin: 40px 0 10px;
}
main form > div.file-input-wrapper div.input-wrapper {
width: 50%;
}
main form > div.file-input-wrapper div.input-wrapper label {
width: 100%;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
font-family: "Amatic SC", cursive;
font-weight: 800;
font-size: 1.5em;
letter-spacing: 0.5px;
}
main
form
> div.file-input-wrapper
div.input-wrapper
label
div.upload-img {
position: relative;
width: 60px;
height: 60px;
background-color: #ffffff;
border-radius: 5px;
display: flex;
justify-content: center;
align-items: center;
}
main
form
> div.file-input-wrapper
div.input-wrapper
label
div.upload-img.then-img {
box-shadow: 8px 8px 0px 0px rgb(251 171 42);
}
main
form
> div.file-input-wrapper
div.input-wrapper
label
div.upload-img.now-img {
box-shadow: 8px 8px 0px 0px rgb(251 87 171);
}
main form > div.file-input-wrapper div.input-wrapper input {
height: 1px;
width: 1px;
visibility: hidden;
}
main form p.instructions {
font-family: "Alata", sans-serif;
font-size: 1em;
font-weight: 500;
text-align: center;
}
main form button {
background-color: #ffffff;
width: fit-content;
height: 50px;
padding: 10px 20px;
border-radius: 5px;
font-family: "Alata", sans-serif;
font-size: 1.5em;
font-weight: bold;
margin: 10px auto;
box-shadow: 5px 5px 0px 0px rgb(0 0 0);
}
main form button:disabled {
background-color: #cfcfcf;
}
main form button:hover:not([disabled]) {
background-color: var(--secondary-color, #3658f8);
color: #ffffff;
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
This is a basic react component. We have a few state objects to store various states. Read about the useState
hook here. We then define a memoized callback using the useCallback
hook. This callback function will check the local storage for an item called tagUrl
. If it exists this means the user has already customized a badge and its url was saved to the local storage. It also checks to see if a tag url image exists on cloudinary and proceeds to create one if it does not exist. We also use a useEffect
hook to run the memoized checkBadgeFrameExists
function. Read about the useCallback
and useEffect
hooks here. handleFormSubmit
just posts form data to the api/images
endpoint for upload. If the upload is successful, it stores the resulting url in the browser’s local storage and then navigates to the /tag
page. The Local Storage API is straightforward and easy to use. I won’t go into the specifics but you can read extensively about it in the Official documentation.
For the HTML we display the tag frame on the left and a form on the right. The form has inputs for the user’s name, how it started the image, and how it’s going image. When the user selects an image, we update the correct state, either thenImage
or nowImage
. Then we use URL.createObjectURL
to create a blob url for that image and use the url to actually display the image. Read about this here.
One thing to note about the styling. You’ll notice that we’ve used a few CSS Variables to define some colors. I won’t go too much into that. Please have a read for yourself on CSS variables here. Let’s define them now. Add the following to styles/globals.css
/* styles/global.css */
:root {
--background-color: #132e74;
--primary-color: #ffee00;
--secondary-color: #3658f8;
}
Code language: CSS (css)
We have also included some custom fonts from Google fonts. Add the following to the top of styles/globals.css
.
/* styles/global.css */
@import url("https://fonts.googleapis.com/css2?family=Alata&family=Amatic+SC:wght@400;700&display=swap");
Code language: CSS (css)
Let’s do the /tag
page. Create a new file named tag.js
inside pages/
folder and paste the following inside.
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
export default function Tag() {
/**
* State to hold the generated tag url
* @type {[string,Function]}
*/
const [tagUrl, setTagUrl] = useState("");
/**
* Loading state
* @type {[boolean,Function]}
*/
const [loading, setLoading] = useState(false);
const getTag = useCallback(() => {
try {
setLoading(true);
// Get the tag url from local storage
const url = window.localStorage.getItem("tagUrl");
// If the url is not empty, update the state
if (url) {
setTagUrl(url);
}
} catch (error) {
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
getTag();
}, [getTag]);
const handleDownloadResource = async () => {
try {
setLoading(true);
const response = await fetch(tagUrl, {});
if (response.ok) {
const blob = await response.blob();
const fileUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = fileUrl;
a.download = `my-virtual-tag.png`;
document.body.appendChild(a);
a.click();
a.remove();
return;
}
throw await response.json();
} catch (error) {
// TODO: Show error message to user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div>
<main>
<div className="flex-wrapper">
{!loading && tagUrl && (
<div className="tag">
<div className="tag-image">
<Image
src={tagUrl}
alt="Virtual Tag"
layout="intrinsic"
width={433}
height={909}
></Image>
</div>
<div className="share-sheet">
<p>
Here is your tag. You can download it and share it with your
friends.
</p>
<button
onClick={() => {
handleDownloadResource();
}}
>
Download Tag
</button>
<Link href="/" passHref>
<button>Back to home</button>
</Link>
</div>
</div>
)}
{loading && (
<div className="loading">
<p>Loading...</p>
<p>Please be patient</p>
</div>
)}
{!loading && !tagUrl && (
<div className="no-tag">
<p>
You have not yet generated a tag or you cleared your browser
storage and we have lost it.
</p>
<Link href="/" passHref>
<button>Create Tag</button>
</Link>
</div>
)}
</div>
</main>
<style jsx>{`
main {
min-height: 100vh;
width: 100vw;
background-color: var(--background-color);
}
main div.flex-wrapper {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
main div.flex-wrapper div.tag {
width: 100%;
display: flex;
flex-flow: row wrap;
justify-content: center;
align-items: center;
gap: 50px;
display: flex;
}
main div.flex-wrapper div.tag div.tag-image {
position: relative;
height: 100vh;
max-width: 400px;
}
main div.flex-wrapper div.tag div.share-sheet,
main div.flex-wrapper div.no-tag,
main div.flex-wrapper div.loading {
max-width: 600px;
margin: auto;
padding: 80px;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
background-color: var(--primary-color);
border-radius: 20px;
box-shadow: 8px 8px 0px 0px rgb(0 0 0);
}
main div.flex-wrapper div.tag div.share-sheet p,
main div.flex-wrapper div.no-tag p,
main div.flex-wrapper div.loading p {
font-family: "Alata", sans-serif;
font-size: 1em;
font-weight: 500;
text-align: center;
}
main div.flex-wrapper div.tag div.share-sheet {
max-width: 450px;
margin: 0;
}
main button {
background-color: #ffffff;
width: fit-content;
height: 50px;
padding: 10px 20px;
border-radius: 5px;
font-family: "Alata", sans-serif;
font-size: 1.5em;
font-weight: bold;
margin: 10px auto;
box-shadow: 5px 5px 0px 0px rgb(0 0 0);
}
main button:disabled {
background-color: #cfcfcf;
}
main button:hover:not([disabled]) {
background-color: var(--secondary-color);
color: #ffffff;
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
Again, this is just a basic React.js component with a few state hooks to hold different states and a memoized callback function called getTag
. The useEffect
hook will run getTag
when the component is first rendered. getTag
will then check the local storage to get the tag url that was saved when we customized our tag in the home page.
handleDownloadResource
gets the tag image as a blob then uses URL.createObjectURL
to create a blob file url that we can then download to the user’s device. Nothing special going on with the HTML and styling.
Only one more thing left now. In our HTML, we’re using the Image
component from Next.js. This component optimizes images that we use in our application. Read about it from the docs. As part of these optimizations, we need to notify Next.js of the domains that we’ll be getting images from. Create a file called next.config.js
at the root of your project if it does not exist and paste the following code inside.
module.exports = {
// Any other options
images: {
domains: ["res.cloudinary.com"],
},
};
Code language: JavaScript (javascript)
We’ve added the res.cloudinary.com
domain. Read more about these optimizations here And that’s it, we’re ready to run our application. Run the following in your terminal.
npm run dev
This builds and runs the application in development mode. Check out the docs on how to build for production. You can find the full code on my Github