What are we building?
In the interest of open source, today I’m going to take you through creating your own photo booth using the same technologies that were used in building the 2021 Red Hat Summit photo booth.
This is what were building!
Check out the live version here!
If you're impatient like me, here's the Github repository so you can get a running start!
makecm / photo-booth-app
Simple React app to generate unique images with Cloudinary, Make.cm and React
The stack
- React: Framework we used to build both of our application and template
- Make: To host our template and generate it into a sharable PNG
- Cloudinary: To host the uploaded photo at a public URL and transform the image prior to sending to the Make template
Putting it all together
1. Template (React)
We’ll be importing our templates, ready made, from the Make Gallery.
2. App (React, Make, Cloudinary, Axios)
For our application, we will be building out the following functionality:
- Uploading and transforming our image with Cloudinary
- Generating our unique photo booth image with Make
- Creating a generative Preview with custom React hooks
If you're interested in the why, read on - however if you just want to crack in, jump down to 0. Getting Started
Why a photo booth?
For Red Hat, the leader in enterprise open source software, they too underwent change - notably, their events evolved. The largest event for them was (and still is) the Red Hat Summit, which brings a global community of customers, partners, and open source contributors together for a multi-day event. At the Red Hat Summit, attendees share, learn and experience a branded manifestation of Red Hat and inspires an audience with the potential of what enterprise open source technology unlocks. It’s about quality not quantity but the Summit regularly attracted ~5,000 in person attendees and was repeated globally through ~20 physical satellite events known as the Red Hat Forum which attract up to 2,000 people each. For the 2020 Summit (and more recently the 2021 event), Red Hat adapted by (appropriately) virtualizing the event - additionally lowering the barrier to entry for attendees (foregoing registration fees), which saw attendance skyrocket. Replicating the excitement of an in-person event is non-trivial. How could they to generate that sense of community when their audience was attending from home? Successfully engaging physical events are abundant with in-person brand activations. Sticker walls, colouring in stations, competitions, trivia, interactive exhibits, t-shirt screen printing , and even photo-booths. There are so many great ways to make a space exciting and engage your audience. The idea of allowing attendees to create sharable and unique user generated content is not a revolutionary idea (see Facebook profile picture frames), however it is an effective way for people to know that they are not alone. That’s why Red Hat deployed strategically placed UGC activations throughout campaigns in 2020 and into 2021 (spearheaded by their Summit experiences) to stoke the fire of community and inclusiveness - made all the more simple with technologies like Make 😀.
// Detect dark theme
var iframe = document.getElementById('tweet-1387579580613992450-724');
if (document.body.className.includes('dark-theme')) {
iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1387579580613992450&theme=dark"
}
Summit 2020 was a massive success, over 40,000 people attended and 17,000 unique Make requests were served from the event photo booth, with many taking to social media. Special shout out has to go to former Red Hat CEO and current IBM CEO Jim Whitehurst for sharing. In 2020 we helped Red Hat execute their first digital photo booth using Make.cm technology inside an iframe on their Summit event site. In 2021 we’re delighted that Red Hat were able build their own the interactive experience seamlessly and directly into several parts of the Summit experience itself.Read more
COVID-19 changed many things for millions of people around the globe. It transformed work, dramatically influencing how we adapt office productivity, travel (or not travel), even the way we interact with others. It was a major decentralization event in our history.
Enter: Photo booth, stage left.
0. Getting Started
Importing our template
Our template is relatively simple for this guide, so instead of spending the time building it we’re going to just import it straight from the Gallery.
Jump across to http://make.cm/gallery
Select the Photo Booth Template, hit the Import this Template button and follow the prompts to sign in/up, creating your template repository on Github and finally importing it into Make.
With all of that complete we will end up on the dashboard of our new Photo Booth template, which will look something like the below image.
While you’re on the dashboard you can do a few things:
- Test out your new template endpoint by sending a few requests in the API playground.
- Navigate to the Github repository that Make created for you. Pull it down, make some changes and push it back up.
- View previously sent requests in the Generation Requests table
Setting up our app
For our application we're going to be using Create React App (CRA). To get started let's go ahead create our app from the terminal.
$ npx create-react-app photo-booth-app
We can then sanitize our newly created react app. You will need to fix up some broken imports in your App.js
and index.js
.
/node_modules
/public
/src
App.css
App.js
App.test.js 🗑
index.css 🗑
index.js
logo.svg 🗑
reportWebVitals.js 🗑
setupTests.js 🗑
.gitignore
package.json
README.md
yarn.lock
While we’re at it, let’s install the dependencies we’ll need.
- minireset.css: simple CSS reset
- axios: to handle our API requests to Cloudinary and Make
- react-device-detect: to determine our download procedures for mobile and desktop devices
- dot-env: to store our Make and Cloudinary keys. While I know they’ll still end up in the built bundle, I’d love to keep them out of my git repo if I decide to push it up
$ yarn add minireset.css axios react-device-detect dotenv
Once those have installed, import minireset.css
into our App. (we’ll import the others in-situ when we get to them).
// App.js
import 'minireset.css';
import './App.css';
function App() {
return <div className="App">{/* OUR APP CODE */}</div>;
}
export default App;
1. Constructing our app structure
We can get started in building out the structure of our photo booth. Our work will fall into three directories:
-
components
: To house our Uploader and Preview components (and their dependencies). -
providers
: We will use React’s Context and Hooks APIs to create a provider to handle our global app state. We did this so we didn’t need to worry about unnecessary prop drilling. -
make
: We separated out the unchangeable parts to the make request so that we can focus on crafting the body of our request to Make.
/node_modules
/public
/src
/components <-- 1
/Preview
index.js
styles.css
/Uploader
index.js
styles.css
/providers <-- 2
appState.js
/make <-- 3
client.js
App.css
App.js
index.js
.env.development
.gitignore
package.json
README.md
yarn.lock
Once we’ve got that we can then add in the main bones of our application in our App.js
, which will look like this.
import './App.css';
function App() {
return (
<div className="App">
<header>
<div>
{/* <Icon /> */}
<h1>React Photo Booth</h1>
</div>
</header>
<div className="container">
{/* <Uploader /> */}
{/* <Preview /> */}
</div>
</div>
);
}
export default App;
Let’s go ahead and drop in our main styles in App.css
, we won’t be touching this at all - but just good to have from the start.
Click here to view and copy the App.css
And while we’re at it let’s round out the header with the proper Icon
.
Create an assets
folder under src
and drop in your icon.svg
.
<svg width="39" height="43" className="icon" viewBox="0 0 39 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.3823 6.52948C26.0644 6.52948 24.8026 7.05119 23.8739 7.9765C22.9455 8.90145 22.4259 10.1537 22.4259 11.4573H16.7185C16.7185 8.63327 17.8446 5.92704 19.8456 3.93336C21.8462 1.94004 24.5575 0.822083 27.3823 0.822083C30.2072 0.822083 32.9184 1.94004 34.9191 3.93336C36.9201 5.92704 38.0461 8.63327 38.0461 11.4573V24.1022H32.3387V11.4573C32.3387 10.1537 31.8191 8.90145 30.8908 7.9765C29.962 7.05119 28.7002 6.52948 27.3823 6.52948ZM19.5722 19.1744C18.2543 19.1744 16.9925 19.6961 16.0638 20.6214C15.1354 21.5464 14.6158 22.7987 14.6158 24.1022H8.90919H8.9084C8.9084 21.2782 10.0345 18.572 12.0355 16.5783C14.0361 14.585 16.7474 13.467 19.5722 13.467C22.3971 13.467 25.1083 14.585 27.109 16.5783C29.11 18.572 30.236 21.2782 30.236 24.1022H24.5286C24.5286 22.7987 24.009 21.5464 23.0806 20.6214C22.1519 19.6961 20.8901 19.1744 19.5722 19.1744ZM9.03181 25.7146C9.37419 27.941 10.4196 30.016 12.0357 31.6262C14.0363 33.6195 16.7476 34.7374 19.5724 34.7374C22.3973 34.7374 25.1085 33.6195 27.1092 31.6262C28.7253 30.016 29.7706 27.941 30.113 25.7146H24.256C24.0136 26.4107 23.6148 27.051 23.0808 27.583C22.1521 28.5083 20.8903 29.03 19.5724 29.03C18.2545 29.03 16.9927 28.5083 16.064 27.583C15.53 27.051 15.1312 26.4107 14.8888 25.7146H9.03181ZM38.0516 25.7146H32.3439L32.3438 37.1143L6.67065 37.1142L6.67067 11.4204L15.1068 11.4205C15.1128 9.41093 15.6137 7.45451 16.5409 5.71273L0.962921 5.71263L0.962891 42.822L38.0516 42.8221L38.0516 25.7146Z" fill="#667EEA"/>
</svg>
In our App.js
we can import it as a ReactComponent
and drop it into the header
.
import './App.css';
import { ReactComponent as Icon } from './assets/icon.svg'
function App() {
return (
<div className="App">
<header>
<div>
<Icon />
<h1>React Photo Booth</h1>
</div>
</header>
<div className="container">
{/* <Uploader /> */}
{/* <Preview /> */}
</div>
</div>
);
}
export default App;
Let's run our server and see what we get.
yarn start
With all of that work our application does absolutely nothing and looks like a dogs breakfast. Let's start to change that.
2. Creating our appState provider
To handle our application state and important data we decided to use a custom hook and React's Context API to provide the state to all of our components, instead of drilling the props and useState functions down to the children components.
I’m not going to go into a tonne of detail on this - however after watching this super easy to follow guide released by Simon Vrachliotis last year I really started to understand how and when to deploy this type of approach.
To get started lets create a file called appState.js
in our providers
directory.
- Inside of that we’ll create a context called
AppStateContext
- which in this context (no pun intended) is our application state. - To make this context available to our components we need to create a provider, which we’ll call
AppStateProvider
. - Finally we’re going to wrap our context in a super simple custom hook called
useAppState
. This allows us to access our context from wherever we are in the component tree.
// providers/appState.js
import React, { createContext, useContext } from "react";
// 1
const AppStateContext = createContext();
// 2
export function AppStateProvider({ children }) {
// Declare our hooks and global data here
// [state, setState] = useState(null)
const value = {
// Import it into the value object here
};
return (
<AppStateContext.Provider value={value}>
{children}
</AppStateContext.Provider>
);
}
// 3
export function useAppState() {
const context = useContext(AppStateContext);
if (!context) {
throw new Error(
"You probably forgot a <AppStateProvider> context provider!"
);
}
return context;
}
To wrap up we need to wrap our App in our AppStateProvider
in the index.js
so that we can access all of the good stuff in the future (once again, no pun intended).
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { AppStateProvider } from "./providers/appState";
ReactDOM.render(
<React.StrictMode>
<AppStateProvider>
<App />
</AppStateProvider>
</React.StrictMode>,
document.getElementById('root')
);
With that done we can actually moving on to building out our components.
3. Uploader
Our Uploader
component will allow users to choose their photo from their device and then we will pre-optimize it and send it to our Cloudinary bucket (that we will set up soon).
Our final component will look something like this and have the following:
- Blank state for the default view when nothing has been uploaded to Cloudinary
- Loading/disabled state when sending to Cloudinary - also includes a progressive loader and a spinner
Building our component
Inside of the components/Uploader
directory lets add an index.js
file with the following structure.
import React from "react";
import axios from "axios";
import './styles.css';
import { useAppState } from "../../providers/appState";
const Uploader = () => {
return (
<>
<div className="Uploader">
<input
type="file"
id="fileupload"
accept="image/*"
title="Upload your Photo"
/>
<label
htmlFor="fileupload"
>
Upload your photo
</label>
</div>
</>
);
}
export default Uploader;
Let’s just get the CSS out of the way by adding a styles.css
file into our Uploader
directory.
Fun fact - for
input
's with thetype=”file”
you are extremely limited in the ability to style it with CSS. So we can actually rely on thelabel
to do all of the heavy lifting for our big Upload button.
Click here to view and copy the Uploader CSS
Once we’ve got that, let’s add it to our App.js
.
// App.js
import './App.css';
import { ReactComponent as Icon } from './assets/icon.svg'
import Uploader from './components/Uploader'
function App() {
return (
<div className="App">
<header>
<div>
<Icon />
<h1>React Photo Booth</h1>
</div>
</header>
<div className="container">
<Uploader />
<div>
{/* <Preview /> */}
</div>
</div>
</div>
);
}
export default App;
Our App should look something like this.
With that done, let’s setup our useState
hooks in our appState
that we can provide to our Uploader
component.
-
imageUrl
: this is where we will store our public URL that Cloudinary returns to us -
isUploading
: this is to trigger our uploading state for our component -
progressIncrement
: this is to contain the current progress of the upload process to Cloudinary
// providers/appState.js
export function AppStateProvider({ children }) {
const [imageUrl, setImageUrl] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [progressIncrement, setProgress] = useState(null);
const value = {
imageUrl,
setImageUrl,
isUploading,
setIsUploading,
progressIncrement,
setProgress,
};
...
}
Inside of our Uploader
component we can then access these values and functions from our provider by using our custom useAppState()
hook.
// components/Uploader/index.js
import React from "react";
import axios from "axios";
import './styles.css';
import { useAppState } from "../../providers/appState";
const Uploader = () => {
const {
setImageUrl,
isUploading,
setIsUploading,
progressIncrement,
setProgress,
} = useAppState();
return (
<>
<div className="Uploader">
<input
type="file"
id="fileupload"
accept="image/*"
title="Upload your Photo"
/>
<label
htmlFor="fileupload"
>
Upload your photo
</label>
</div>
</>
);
}
export default Uploader;
Creating our Cloudinary Account
With that ready to go let’s go ahead and create our Cloudinary account. To do so jump across to Cloudinary and sign up for free.
For the purposes of this tutorial the free plan is pretty comprehensive and will be more than enough for our purposes. When you sign up, Cloudinary will assign you a cloud name (the name of your bucket), but you can change that if you want.
To send our assets to our newly created bucket, we’ll be using the Cloudinary’s unsigned option for using the Upload API, which was deemed to be the easiest method for uploading to Cloudinary. While it is a little less secure than signing our method it does allow us the quickest path to MVP.
For more robust production ready solutions I’d do some more research into signed methods of Upload.
By using the unsigned upload option we need the following information:
-
cloud_name
: the name of our bucket -
upload_preset
: defines what upload options we want to apply to our assets
While our cloud_name
has already been created for us (on account sign up), to create an upload_preset
go to:
- Your Settings (cog icon)
- Upload Settings
- Scroll down to the Upload Presets section.
By default there should already be a default one called ml_default
.
Create another preset and set the signing method to unsigned
. Everything else can remain as is.
With your upload preset created, copy its name (along with the cloud name that can be found on the dashboard of your Cloudinary account) and paste those into a .env.development
file (that you can create on the root directory).
When we update these you will need to restart your local server again.
// .env.development
REACT_APP_CLOUDINARY_UPLOAD_PRESET=xxx
REACT_APP_CLOUDINARY_CLOUD_NAME=yyy
Optimizing and sending our photo to Cloudinary
Now that we’ve got our bucket setup we can create our function to handle the file upload. Ultimately we’re doing the following:
- Trigger our
isUploading
state. - Get our file.
- Optimise and base64 our file so that we can send it to Cloudinary - for this we’ll be creating a callback function called
getBase64Image
to do the heavy lifting (which I’ll talk to in a second). - Send it via
axios
and store theprogressIncrement
that is periodically returned. - Store the response in our
imageUrl
state once finished.
We’ll call our function onInputChange
and fire it onChange
of our input
.
// components/Uploader/index.js
import React from "react";
import axios from "axios";
import './styles.css';
import { useAppState } from "../../providers/appState";
const Uploader = () => {
const {
imageUrl,
setImageUrl,
isUploading,
setIsUploading,
progressIncrement,
setProgress,
} = useAppState();
const onInputChange = (event) => {
// 1
setIsUploading(true);
// 2
for (const file of event.target.files) {
const uploadPreset = process.env.REACT_APP_CLOUDINARY_UPLOAD_PRESET;
const cloudName = process.env.REACT_APP_CLOUDINARY_CLOUD_NAME;
const url = `https://api.cloudinary.com/v1_1/${cloudName}/upload`;
// 3
getBase64Image(file, (base64Value) => {
const data = {
upload_preset: uploadPreset,
file: base64Value,
};
// 4
// Cloudinary provides us a progressEvent that we can hook into and store the current value in our state
const config = {
onUploadProgress: function (progressEvent) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(progress);
},
};
axios
.post(url, data, config)
.then((response) => {
// 5
setIsUploading(false);
setImageUrl(response.data.url);
})
.catch((error) => {
console.log(error);
setIsUploading(false);
});
});
}
};
return (
<>
<div className="Uploader">
<input
type="file"
id="fileupload"
accept="image/*"
title="Upload your Photo"
onChange={onInputChange}
/>
<label
htmlFor="fileupload"
>
Upload your photo
</label>
</div>
</>
);
}
export default Uploader;
And this is what our getBase64Image
function looks like. Paste this just above the onInputChange
function.
- We read the file as a DataURI
- Create the bounds of our image and then calculate our canvas. In this case I’m creating a canvas as a max width and height of 1600px and then calculating the image based on that.
- Compose our image on our canvas
- Base64 our image as a JPG and pass it back to our onInputChange function
const getBase64Image = (file, callback) => {
// 1
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
// 2
let width = "";
let height = "";
const MAX_WIDTH = 1600;
const MAX_HEIGHT = 1600;
const img = new Image();
img.style.imageOrientation = "from-image";
img.src = event.target.result;
img.onload = () => {
width = img.width;
height = img.height;
if (width / MAX_WIDTH > height / MAX_HEIGHT) {
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
}
// 3
const canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
canvas.width = width;
canvas.height = height;
canvas.style.imageOrientation = "from-image";
ctx.fillStyle = "rgba(255,255,255,0.0)";
ctx.fillRect(0, 0, 700, 600);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(img, 0, 0, width, height);
// 4
const data = ctx.canvas.toDataURL("image/jpeg");
callback(data);
};
};
reader.onerror = function (error) {
console.log("Error: ", error);
};
};
With that in place, crack open your react dev tools and check out our state in our AppStateProvider
and try and upload an image. Ultimately we should see our isUploading
change, our progressIncrement
tick up as it uploads and our imageUrl populate once uploading is finished.
Transforming our image
Cloudinary also offers us the ability to make on the fly adjustments to the images we’ve uploaded with their Transformations API.
For our photo booth case I want to always make sure that no matter where a face is in the image, that it will always be ‘wholly’ visible in the format.
To do that I’m going to push our response URL into a function called imagePosition
prior to storing it in our state so that it has the necessary transformation on it.
All we’re doing here is splitting our url apart at the /
and then inserting our transformation into the URL and joining it back together.
const imagePosition = (url) => {
const arr = new URL(url).href.split("/");
const transformation = 'w_1080,h_1080,c_thumb,g_face/w_1000';
console.log('hey')
arr.splice(6, 0, transformation)
const joinedArr = arr.join('/')
return joinedArr
};
Finally instead of pushing our response.data.url
straight into our imageUrl state, we’ll first run it through our imagePosition
function.
// components/Uploader/index.js
...
axios
.post(url, data, config)
.then((response) => {
setIsUploading(false);
setImageUrl(imagePosition(response.data.url));
})
.catch((error) => {
console.log(error);
setIsUploading(false);
});
});
What difference does the transformation make?!
In the case I just used above here is what happens to my image with and without transformations.
Finalizing our states
Our uploader works, it just looks awful, so let’s create our uploading state.
- Create 2
spans
inside of ourlabel
and toggle between the two depending on ourisUploading
state. - Add some specific styling to our label background when
progressIncrement
increases. We can use a super simple, yet effective ‘hack’ with linear-gradient. - Add our
disabled
prop to ourinput
so we can lock it when a file is uploading
return (
<>
<div className="Uploader">
<input
type="file"
id="fileupload"
accept="image/*"
onChange={onInputChange}
title="Upload your Photo"
{/* 3 */}
disabled={isUploading}
/>
<label
htmlFor="fileupload"
{/* 2 */}
style={{
background: `linear-gradient(90deg, #4C51BF ${progressIncrement}%, #667EEA ${progressIncrement}%)`
}}
>
{/* 1 */}
<span
className="upload"
style={{
transform: isUploading && 'translateY(300%)'
}}
>
Upload your photo
</span>
<span
className="uploading"
style={{
top: isUploading ? '0' : '-180%'
}}
>
Uploading
<Spinner styles={{
marginLeft: '1rem'
}} />
</span>
</label>
</div>
</>
);
To cap it off we’ll need to setup our Spinner
component that we call in our Uploading
span. Inside of the Uploader
directory create a new file called spinner.js
.
// components/Uploader/spinner.js
import React from "react";
export default function Spinner({ size, styles }) {
return (
<div
className={`${size === 'small' ? 'small' : ''} Spinner`}
style={styles}
/>
);
}
And don’t forget to import it at the top of the Uploader
component
import Spinner from './spinner'
With that complete you should have a functional <Uploader />
component, returning you a beautifully transformed imageUrl
and reflecting the proper state to the user.
4. Generating with Make.cm
Now that we’ve got our image from Cloudinary, let’s generate our photo so we can do something with it.
Let’s jump over to our .env.development
file and add two new variables.
When we update these you will need to restart your local server again.
// .env.development
REACT_APP_CLOUDINARY_UPLOAD_PRESET=xxx
REACT_APP_CLOUDINARY_CLOUD_NAME=yyy
REACT_APP_MAKE_KEY=
REACT_APP_MAKE_URL=
To find your API key and URL jump across to Make and select your photo booth template that you imported earlier. If you’re yet to import your template go here and import it.
Once you’re on the template dashboard you can grab the key and URL from the API playground view and paste it into your .env.development
file.
Creating our hooks
With that done we’ll create the useState
hooks we’ll need to handle our Make request and the response of our generated asset in our appState
.
Our isGenerating
hook will handle our loading state for when the request is in flight, while our generatedAvatar
will store the result that Make sends back to our application.
// providers/appState.js
...
const [isGenerating, setIsGenerating] = useState(false);
const [generatedAvatar, setGeneratedAvatars] = useState(null);
const value = {
...
isGenerating,
setIsGenerating,
generatedAvatar,
setGeneratedAvatars,
}
Like we’ve done before, consume our newly created hooks in the useAppState()
hook in the App.js
file.
function App() {
const {
...
isGenerating,
setIsGenerating,
generatedAvatar,
setGeneratedAvatars,
} = useAppState();
...
}
Developing our axios client and request
Like we did for the Uploader
component, we will use axios
to handle our Make POST request to generate our photo booth template into a PNG.
In our make
directory let’s create a client.js
file.
With our client
we’ll use axios.create
to create a default instance for our request. I opted to do this because it keeps all of the headers and procedural code out of our App.js
.
It also gives us a client
that we can re-use down the track for different implementations.
// make/client.js
import axios from "axios";
export const client = axios.create({
headers: {
'Content-Type': 'application/json',
'X-MAKE-API-KEY': process.env.REACT_APP_MAKE_KEY
}
});
const url = process.env.REACT_APP_MAKE_URL
export function make(data) {
return client.post(url, data)
}
We can then import our make
client into our App.js
.
import { useEffect } from 'react';
import { make } from "./make/client"
We will then use a React useEffect
to trigger our request to Make. useEffect
's are great because you can trigger it based on a value updating. In our case we want to trigger the useEffect on the the imageUrl
updating.
// App.js
function App() {
...
useEffect(() => {
...
}, [imageUrl]);
With our useEffect
in place we want to create our function to send our avatar to Make for generation.
- First set our
isGenerating
state totrue
so that we can trigger a loading state. - We can then define our
data
that we want to pass to our Make template. This is split up into 4 areas: -
customSize
: specifies the size of our generated filed -
format
: specifies the file type to be generated to -
data
: specifies any data we want to send to our template pre-generation. In this case our template knows to accept aphoto
string. We will then set that to ourimageUrl
. -
fileName
: this can be whatever you want it to be - We then call our
make
client (that we created and imported just before) and send ourdata
to it. - We wait and then store the
response
into ourgeneratedAvatar
state and turn off ourisGenerating
state
We also need to add any other dependencies into our useEffect
as we will get a linting error.
useEffect(() => {
if (imageUrl !== null) {
// 1
setIsGenerating(true);
// 2
const data = {
customSize: {
width: previewSize.width,
height: previewSize.height,
unit: 'px',
},
format: "png",
fileName: "image",
data: {
photo: imageUrl,
}
};
// 3
make(data)
.then((response) => {
// 4
console.log(response.data.resultUrl)
setGeneratedAvatar(response.data.resultUrl);
setIsGenerating(false);
})
.catch((error) => {
console.log(error);
setIsGenerating(false);
});
}
}, [
imageUrl,
previewSize.height,
previewSize.width,
setIsGenerating,
setGeneratedAvatar
]);
If you try it now, crack open the console and see what comes through.
🥳 Looks great, doesn’t it?
Creating our Download button
With our logic all setup let’s create a button to be able to download our photo booth file once it’s ready. In the return
of our App.js
we can add a simple a
tag and set the generatedAvatar
that Make returns to us as the href
.
One thing we’ll want to do is make sure that this button only shows once our request to Make is in flight. So we know that when our imageUrl
exists we can show this button.
On the inverse we want to remove our Uploader
once it’s finished its job of uploading. So we can check to see if imageUrl
is not populated.
return (
<div className="App">
{!imageUrl && (<Uploader />)}
{imageUrl && (
<div className="controlPanel">
<a
className={`download ${isGenerating ? 'disabled' : 'false'}`}
target="_blank"
rel="noreferrer noopener"
href={generatedAvatar && generatedAvatar}
>
{isGenerating && (
<Spinner styles={{ marginRight: '1rem' }} size="small" />
)}
{isGenerating ? "Generating..." : "Download"}
</a>
</div>
)}
</div>
</div>
);
We're recycling the Spinner
component we created for the Uploader
, so remember to import it into your App.js
.
import Spinner from './components/Uploader/spinner'
Now, when you upload a photo to Cloudinary it will automatically trigger the request to Make and then store the result in our Download button.
Amazing 🔥
Mobile v Desktop download
There is one problem, however…
If a user was to use our photo booth on a mobile, their browser wouldn’t know where to download the image to (especially on an iPhone). So what we need to do is change our download behavior depending on if you’re accessing the photo booth on a mobile/tablet device or a desktop.
The Make API actually provides you a parameter to be able to control the behavior of ‘displaying’ your generated artwork, called contentDisposition
.
With contentDisposition
Make will set a header on our response to tell the browser to either display the file as an attachment
(so downloading it and saving it locally - default) or inline
(which opens it in a new tab). In this case we would want to do the following:
-
If mobile: display our file as
inline
(so that a user can save it to Photos or something similar) -
If desktop: display our file as an
attachment
(and drop it straight to our local file system - most probably our Downloads folder).
The final piece to this puzzle is how we’re going to detect if our user is using the photo booth from a mobile or a desktop. For this implementation I’m going to use react-device-detect.
Caveat here, this may not be the best way to do this for certain implementations, but it’s API surface area is low and easy to manage.
// App.js
import { isMobile } from "react-device-detect";
// App.js
useEffect(() => {
if (imageUrl !== null) {
setIsGenerating(true);
const data = {
customSize: {
width: previewSize.width,
height: previewSize.height,
unit: 'px',
},
format: "png",
fileName: "image",
contentDisposition: isMobile ? "inline" : "attachment",
data: {
photo: imageUrl,
}
};
make(data)
.then((response) => {
console.log(response.data.resultUrl)
setGeneratedAvatar(response.data.resultUrl);
setIsGenerating(false);
})
.catch((error) => {
console.log(error);
setIsGenerating(false);
});
}
}, [imageUrl]);
Now users will be able to strike a pose on their phone and get their newly minted photo straight to their phone.
5. Preview
The last major piece to this puzzle is giving our user a preview of what they’re creating, of which I see two ways we can handle it:
1. We persist our Loading state on the Upload button until the Make request is fulfilled and then just set the returned image into a container.
- Pros: easier to develop, shows the user the actual file.
- Cons: the user could be waiting a while (for both Cloudinary, Make and the application to fulfil the requests).
2. We create a Preview component and give the user a visual preview (of what Make is about to send us) straight after our Cloudinary image is returned to our application.
- Pros: We can break up the loading states between Cloudinary and Make, we can create a more visually interesting preview display.
- Cons: Takes longer to develop, what the user sees in the app may be slightly different to what Make sends back (especially since this template is using generative shapes).
For me, I see option 2 as a no brainer - I don’t want a user to be sitting and looking at a loading spinner for 10s, and it also means that we can do some visually interesting effects with the overlay.
For our Preview we will be doing the following:
- Creating our component
- Calculating our preview container so that it always fits to the space
Creating our component
In our Preview
directory, create a new index.js
file and drop the following in
// components/Preview/index.js
import './styles.css'
import { useAppState } from "../../providers/appState";
import { ReactComponent as Icon } from '../../assets/icon.svg'
const Preview = () => {
const {
imageUrl,
} = useAppState();
return (
<div className={`inner ${imageUrl ? 'uploaded' : 'blank'}`}>
<div className="Preview">
<Icon />
<div className="preview-container">
{imageUrl && <img alt="avatar" src={imageUrl} />}
</div>
</div>
</div>
)
}
export default Preview;
We can add our CSS into our styles.css
file in that same directory.
Click here to view and copy the Preview CSS
Finally, we can add our Shapes
component into our Preview
directory. With this component all of the generated assets will have their own unique touch to them.
// components/Preview/shapes.js
const Shapes = () => {
function getRandomLength() {
return Math.floor(Math.random() * 500 + 100);
}
function getRandomGap() {
return Math.floor(Math.random() * 500 + 900);
}
return (
<div style={{ overflow: 'hidden' }}>
<svg
className="svg-shapes"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="100 100 600 600"
preserveAspectRatio="xMidYMid slice"
>
{[0, 1].map((item) => (
<circle
key={item}
r={Math.floor(Math.random() * 500) + 100}
cx={Math.floor(Math.random() * 500)}
cy={Math.floor(Math.random() * 500)}
strokeWidth={Math.floor(Math.random() * 1000 + 75)}
strokeDasharray={`${getRandomLength()} ${getRandomGap()}`}
/>
))}
</svg>
<svg style={{ pointerEvents: 'none' }}>
<defs>
<linearGradient id="bggrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#EF6690' }} />
<stop
offset="100%"
style={{ stopColor: '#FF9E90' }}
/>
</linearGradient>
</defs>
</svg>
</div>
);
};
export default Shapes;
And we can then import our Shapes
into our Preview
.
import './styles.css'
import { useAppState } from "../../providers/appState";
import { ReactComponent as Icon } from '../../assets/icon.svg'
import Shapes from './Shapes'
const Preview = () => {
const {
imageUrl,
} = useAppState();
return (
<div className={`inner ${imageUrl ? 'uploaded' : 'blank'}`}>
<div className="Preview">
<Icon />
<div className="preview-container">
{imageUrl && <img alt="avatar" src={imageUrl} />}
</div>
<Shapes />
</div>
</div>
)
}
export default Preview;
Finally, we can add our Preview
into our App.js
.
import './App.css';
import { ReactComponent as Icon } from './assets/icon.svg'
import Uploader from './components/Uploader'
import Preview from './components/Preview';
function App() {
...
return (
<div className="App">
<header>
<div>
<Icon />
<h1>React Photo Booth</h1>
</div>
</header>
<div className="container">
{!imageUrl && (<Uploader />)}
<Preview />
{imageUrl && (
<div className="controlPanel">
<a
className={`download ${isGenerating ? 'disabled' : 'false'}`}
target="_blank"
rel="noreferrer noopener"
href={generatedAvatar && generatedAvatar}
>
{isGenerating && (
<Spinner styles={{ marginRight: '1rem' }} size="small" />
)}
{isGenerating ? "Generating..." : "Download"}
</a>
</div>
)}
</div>
</div>
);
}
export default App;
Our Preview is there but it will look a bit mangled, so let’s make it better…
Calculating our preview size
To make our preview better we're going to calculate the size of it dynamically so that it will always fit in the available space of its parent container.
For that we’re actually going to be creating a custom hook to give us the correct CSS transform controls to match our browser size.
Firstly let’s jump over to the appState
and we’re going to create a new const
called previewSize
. Inside previewSize
we will create an object for our size.
// providers/appState.js
const previewSize = {
width: 1080,
height: 1080,
}
const value = {
...
previewSize,
};
We’ll then create a new file in our Preview
directory called usePreviewSize.js
. It will allow us to send it the ref
of an element and with that it will return us some calculated results based on the previewSize
it consumes from our useAppState()
hook.
// components/Preview/usePreviewSize.js
import { useEffect, useState } from "react";
import { useAppState } from '../../providers/appState'
export function usePreviewSize(previewRef) {
const [calcSize, setCalcSize] = useState(null)
const {
previewSize,
} = useAppState()
useEffect(() => {
function fitPreview() {
const pixelH = previewSize.height,
pixelW = previewSize.width,
containerH = previewRef.current.clientHeight,
containerW = previewRef.current.clientWidth,
heightRatio = containerH / pixelH,
widthRatio = containerW / pixelW,
fitZoom = Math.min(heightRatio, widthRatio)
setCalcSize({
pixelW: pixelW,
pixelH: pixelH,
fitZoom: fitZoom,
})
} fitPreview()
window.onresize = resize;
function resize() {
fitPreview()
}
}, [previewSize, previewRef])
return calcSize
}
In our Preview
component we can then do the following:
- Setup our
ref
on our.inner
div - Send it to our
usePreviewSize()
hook - Create an object of styles based on the calculations
- Add that to our
.Preview
div
import React, { useRef } from 'react';
import './styles.css'
import { useAppState } from "../../providers/appState";
import { usePreviewSize } from "./usePreviewSize"
import { ReactComponent as Icon } from '../../assets/icon.svg'
import Shapes from './Shapes'
const Preview = () => {
const {
imageUrl,
} = useAppState();
// 1 & 2
const previewRef = useRef(null)
const size = usePreviewSize(previewRef)
// 3
const calcStyles = {
width: size && size.pixelW + 'px',
height: size && size.pixelH + 'px',
transform: size && `scale(${size.fitZoom}) translate(-50%, -50%)`,
filter: imageUrl ? 'blur(0)' : 'blur(30px)',
}
return (
<div className={`inner ${imageUrl ? 'uploaded' : 'blank'}`} ref={previewRef}>
{/* 4 */}
<div className="Preview" styles={calcStyles}>
<Icon />
<div className="preview-container">
{imageUrl && <img alt="avatar" src={imageUrl} />}
</div>
<Shapes />
</div>
</div>
)
}
export default Preview;
And voila! We’ve got a nicely sized preview (and even a cheeky blur effect when in the blank state)
6. Finishing Up
At this point, we’re mostly done! Give yourself a huge pat on the back, because while all of the components are quite simple, there can be a few little hairy issues to overcome.
This part is completely optional, but if you want to round it all out let’s add a button so that a user can start again if they’re not happy with the result.
Creating our StartAgain button
Let’s first create a function that will reset all of our important state back to the initial values.
// App.js
const startAgain = () => {
setImageUrl(null);
setProgress(null);
setGeneratedAvatar(null);
};
Inside of our return we can then add our button.
// App.js
return (
<div className="App">
<header>
<div>
<Icon />
<h1>React Photo Booth</h1>
</div>
{imageUrl && (
<button
className="reset"
onClick={function () {
startAgain();
}}>
Try Again
</button>
)}
</header>
...
</div>
);
Congratulations! You’ve made it to the end 🎉🎉🎉.
Thank you so much for following along and I hope you've learnt a few things along the way. Here are some helpful resources that might interest you moving forward:
makecm / photo-booth-app
Simple React app to generate unique images with Cloudinary, Make.cm and React
makecm / photo-booth-template
A generative image template built for the Make a Photo Booth guide.
Build a "Name Picker" app - Intro to React, Hooks & Context API
Or check out the first Make guide on creating a PDF with Make and React.
Make a PDF with React & Make.cm and avoid the pain of ongoing service management [Part 1/2]
James Lee for Make.cm ・ Mar 25 '21
Make a PDF with React & Make.cm and avoid the pain of ongoing service management [Part 2/2]
James Lee for Make.cm ・ Mar 25 '21
If you have any questions, got stuck somewhere or want to pass on some feedback, jump onto twitter and message me directly @jamesrplee or you can also reach me at @makecm_.
Happy Making 🚀
Top comments (2)
For those interested, here's a self-hosted PHP-based Cloudinary replacement.
glide.thephpleague.com/
Looks cool! thanks for sharing Matthew