DEV Community

Cover image for Building a photo gallery app from scratch with Chakra UI
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Building a photo gallery app from scratch with Chakra UI

Written by Foysal Ahamed✏️

React is now one of the most battle-tested and mature front-end frameworks in the world, and Express.js is its counterpart among back-end/server frameworks. If you’re building an app today, you can’t pick a better duo than this. In this post, I will walk you through building a complete app using these two frameworks, plus Chakra UI for component styling.

Before we dive in, for the impatients like me, here’s the entire codebase on GitHub. Feel free to clone it and take it for a spin.

Is this post for me?

As a self-taught full-stack dev, I always find myself scouring the web for blog posts/tutorials that build out entire applications from scratch and demonstrate one or several features commonly seen in real-life apps. This kind of post helps devs across a broad spectrum of skill sets and experience.

Beginners learn how to glue together new concepts they have learned recently and turn them into a complete and usable app. Devs with an intermediate level of knowledge can learn how to organize, structure, and apply best practices when building full-stack apps.

So, if you’re just getting into the JS ecosystem — or if you have already built one or two apps but sometimes get confused about whether you’re doing it right — this post is for you.

Having said that, to read and complete this tutorial in one sitting, you will need to have:

  • A clear understanding of basic JS concepts and some familiarity with ES6 syntax
  • Used React at least once and have some familiarity with common concepts such as states, components, renders, etc.
  • Familiarity with the concept of REST APIs
  • Used a relational database
  • Used Node.js and Express.js for a web server app
  • A working JS ecosystem set up on your machine, i.e., the latest versions of npm, Node.js, etc. installed

If you find yourself missing any of the above items, worry not! The web has plenty of content that will help you get started and prepared for this post.

Please note that my primary OS is Ubuntu, so all the commands in this post assume you have a *nix system.

LogRocket Free Trial Banner

Laying the groundwork

Before starting any new project, it is easy to get impatient and start writing code right away. However, it is always a good idea to plan out your features and workflow first — at least that’s what I always do. So let’s make a plan for how our app will work.

Our app will have two main parts. One is the client-side React app that lets me upload photos through my browser. The uploaded photos are then shown in a gallery view.

The other part is a server-side API that receives a photo upload, stores it somewhere, and lets us query and display all the uploaded photos.

Before all that programming mumbo-jumbo, however, let’s give our app a catchy name. I’m calling it photato, but feel free to give it a better name yourself, and let me know what you come up with. 🙂

OK, time to code. Let’s make container folders for our app first:

mkdir photato && cd $_
mkdir web
mkdir api
Enter fullscreen mode Exit fullscreen mode

We will start by creating our front-end React app. React comes with a handy tool that lets you bootstrap a React app real fast:

cd web
npx create-react-app web
Enter fullscreen mode Exit fullscreen mode

Now you should have a bunch of files and folders in the web/ folder, and the output will tell you that by going into the directory and running yarn start, you can make your app available at http://localhost:3000.

Create React App Default Files

If you have built websites/web apps before, you might be familiar with the struggle of building UIs with raw HTML and CSS. UI libraries like Bootstrap, Semantic UI, Material Kit, and countless others have long been the saviors of full-stack devs who can’t produce “dribbble famous”-quality design.

In this post, we will look away from the more common, traditional UI libraries mentioned above and use Chakra UI, built with accessibility in mind on the utility-first CSS framework Tailwind CSS.

Following the Chakra UI get-started guide, run the following commands in your React app’s root directory:

yarn add @chakra-ui/core @emotion/core @emotion/styled emotion-theming
Enter fullscreen mode Exit fullscreen mode

Chakra UI allows you to customize its look and feel through theming very easily, but for this post, we will stick to its default styling.

The last thing we need before we can start coding is one more library to get a pretty-looking gallery:

yarn add react-photo-gallery
Enter fullscreen mode Exit fullscreen mode

Our app’s code will be encapsulated within the src/ directory, so let’s take a look at it. create-react-app gave us a bunch of files, and with the help of Chakra UI, we can basically get rid of all the CSS stuff. Remove the App.css, index.css, and logo.svg files:

cd src
rm -r App.css index.css logo.svg
Enter fullscreen mode Exit fullscreen mode

This gives us a clean base on which to start building. Now let’s look at our setup for the server API app. Navigate back to the api/ folder and create a new file by running the following commands:

cd ../../api
touch package.json
Enter fullscreen mode Exit fullscreen mode

Now copy and paste the following code into the package.json file:

{
  "name": "api",
  "version": "1.0.0",
  "description": "Server api for photato",
  "main": "dist",
  "author": "Foysal Ahamed",
  "license": "ISC",
  "entry": "src/index.js",
  "scripts": {
    "dev": "NODE_ENV=development nodemon src/index.js --exec babel-node",
    "start": "node dist",
    "build": "./node_modules/.bin/babel src --out-dir dist --copy-files",
    "prestart": "npm run -s build"
  },
  "eslintConfig": {
    "extends": "eslint:recommended",
    "parserOptions": {
      "ecmaVersion": 7,
      "sourceType": "module"
    },
    "env": {
      "node": true
    },
    "rules": {
      "no-console": 0,
      "no-unused-vars": 1
    }
  },
  "dependencies": {
    "cors": "^2.8.4",
    "express": "^4.13.3",
    "mysql2": "^1.6.1",
    "sequelize": "^5.18.4"
  },
  "devDependencies": {
    "@babel/cli": "^7.1.2",
    "@babel/core": "^7.1.2",
    "@babel/node": "^7.0.0",
    "@babel/plugin-proposal-class-properties": "^7.1.0",
    "@babel/preset-env": "^7.1.0",
    "eslint": "^3.1.1",
    "eslint-config-airbnb": "^17.1.0",
    "eslint-plugin-jsx-a11y": "^6.2.1",
    "nodemon": "^1.9.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we have quite a few dev dependencies, and they are necessary to enable writing our app using the latest ES6 syntax transpiled through Babel.

Babel is a magnificent tool and full of wonderful features, but for our purposes, you need to know almost nothing about it. In our case, we just need to create a .babelrc file alongside the package.json file and put the following config in it:

{
    "presets": [[
        "@babel/preset-env",
        {
            "targets": {
                "node": "current"
            }
        }
    ]],
    "plugins": [
        "@babel/plugin-proposal-class-properties"
    ]
}
Enter fullscreen mode Exit fullscreen mode

There’s also a few other dependencies, like Express and Sequelize, and we will see their usage later. That’s all the setup we need for our server app, but before we move on, let’s install all the packages by running the npm install command in the root of the api/ folder. This command will generate a node_modules/ folder and a package.lock.json file.

Photo gallery with Chakra UI and React

We will start with the App.js file. Let’s clean up the generated code and fill it with the following code:

import React from 'react';
import { ThemeProvider } from '@chakra-ui/core';

import AppContainer from './app.container';

function App() {
    return (
        <div>
            <ThemeProvider>
                <AppContainer />
            </ThemeProvider>
        </div>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This simplifies our entry component and delegates the actual logic to another container named AppContainer, which is wrapped within ThemeProvider from Chakra UI. The ThemeProvider component ensures all of its children can be styled with the Chakra UI theme or any custom theme you may want to pass to it.

With that out of the way, we will never have to touch App.js again. Let’s create the new file touch src/app.container.js and fill it with the following code:

import React from 'react';
import PhotoGallery from 'react-photo-gallery';

import Header from './header.component';

function AppContainer() {
    const photos = [{
            src: 'http://placekitten.com/200/300',
            width: 3,
            height: 4,
        },
        {
            src: 'http://placekitten.com/200/200',
            width: 1,
            height: 1,
        },
        {
            src: 'http://placekitten.com/300/400',
            width: 3,
            height: 4,
        },
    ];

    return (
        <>
            <Header/>
            <PhotoGallery
                photos={photos}
            />
        </>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This component renders two other components, Header and PhotoGallery, where PhotoGallery is provided by the npm photo gallery lib. Note that we are passing a photos array containing placeholder images to the PhotoGallery component. We will get back to this later in the post and replace the heartwarming kitten photos with our own uploaded photos.

The other component, Header, is being imported from a file that doesn’t exist yet, so let’s create it: touch src/header.component.js. Put the following code in the file:

import React from 'react';
import { Flex, Button, Text } from '@chakra-ui/core';

function Header ({
    isUploading = false, 
    onPhotoSelect,
}) {
    return (
        <Flex 
            px="4"
            py="4"
            justify="space-between"
        >
            <Text 
                as="div"
                fontSize="xl" 
                fontWeight="bold" 
            >
                <span 
                    role="img" 
                    aria-labelledby="potato"
                >
                    🥔 
                </span> 
                <span 
                    role="img" 
                    aria-labelledby="potato"
                >
                    🍠 
                </span> 
                Photato
            </Text>

            <Flex align="end">
                <Button 
                    size="sm"
                    variant="outline"
                    variantColor="blue"
                    isLoading={isUploading}
                    loadingText="Uploading..."
                >
                    Upload Photo     
                </Button>
            </Flex>
        </Flex>
    );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

If you followed all the above steps, the app in your browser should render something like this:

First Look At The App: Kitten Photos

Let’s break down what we’ve done so far.

The Header component wraps all its children in a Chakra UI Flex component that renders an HTML div element with CSS style display: flex. Being a utility-based CSS framework, Chakra UI allows you to pass various props to its components to style them to your liking, and you will see this used throughout our app.

In our wrapper Flex component, px and py props give it a nice horizontal and vertical padding (respectively), and the justify="space-between" prop ensures that the elements inside it are rendered with equal spacing between them. If you’re not very familiar with CSS flexbox, I highly encourage you to learn more about this amazing layout tool.

Inside the Flex container, we have a Text on the left of the screen and a Button for uploading new photos on the right of the screen. Let’s take a closer look at the Button here.

We use size="sm" to give it a smaller size, but you can play around with lg, xs, etc. values to change the size. The variant="outline" prop makes it a bordered button instead of filling it with color — and speaking of color, variantColor="blue" makes the border and the text blue. There are several other colors available out of the box from Chakra UI, and I would highly recommend reading up on it.

So far, we have been focused on the looks. Let’s talk about the functionality. This component is a great example of one of the core principles of writing clean and easily maintainable front-end code. It’s a dumb component that only renders the markup, and there is no logic being handled. To make it functional, we pass props to it from the parent. It expects two props:

  • isUploading, which is a boolean and defaults to false. This prop determines the state of the Upload Photo button. When it is true, the button will go into a loading state to give the user a feedback that uploading is happening in the background.
  • onPhotoSelect, which is a function that will be triggered when the user selects a new photo to upload. We will circle back to this later.

This way of writing components really helps you plan out the functionality and architecture one small chunk at a time. Without implementing the actual logic, we have already planned out how the button will work based on the requirements of our app.

We have a solid and functional base for our front-end app now, so let’s pause here for a moment and start setting up our back end.

Server API

The entry point for our server api will be the src/index.js file, so let’s create that:

mkdir src
touch index.js
Enter fullscreen mode Exit fullscreen mode

Then put the following code in that file:

import http from 'http';
import cors from 'cors';
import express from 'express';
import { Sequelize } from 'sequelize';

const config = {
    port: 3001,
    database: {
        username: "root",
        password: "admin",
        host: "localhost",
        port: "3306",
        dialect: "mysql",
        database: "photato",
    }
};

let app = express();
app.server = http.createServer(app);

// 3rd party middlewares
app.use(cors({}));

// connect to db
const database = new Sequelize(config.database);

database.sync().then(() => {
    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.server.listen(config.port, () => {
        console.log(`Started on port ${app.server.address().port}`);
    });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

This is a bare-bones setup; let’s break it down block by block.

import http from 'http';
import cors from 'cors';
import express from 'express';
import { Sequelize } from 'sequelize';
Enter fullscreen mode Exit fullscreen mode

Imports the necessary modules from Node’s built-in HTTP package and other third-party packages installed through npm.

const config = {
    port: 3001,
    database: {
        username: "root",
        password: "admin",
        host: "localhost",
        port: "3306",
        dialect: "mysql",
        database: "photato",
    }
};
Enter fullscreen mode Exit fullscreen mode

This defines configurations for the database and server port where the app will be available. You will need to change the database password and username based on your MySQL database setup. Also, make sure you create a new database schema named photato in your db.

Please note that in production-ready applications, you would pass the configs from env var instead of hardcoding them.

let app = express();
app.server = http.createServer(app);

// 3rd party middlewares
app.use(cors({}));
Enter fullscreen mode Exit fullscreen mode

This initializes the Express app and creates a server instance using Node’s http.createServer method. Express allows plugging in various functionalities through middlewares. One such middleware we are going to use enables CORS requests for our API.

Right now, we are allowing CORS requests from any origin, but you can add more fine-grained config to allow requests only from your front-end app’s domain name for security purposes.

// connect to db
const database = new Sequelize(config.database);

database.sync().then(() => {
    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.server.listen(config.port, () => {
        console.log(`Started on port ${app.server.address().port}`);
    });
});
Enter fullscreen mode Exit fullscreen mode

This initializes a Sequelize instance that connects to our MySQL database based on our config. Once the connection is established, it adds a handler for the / endpoint of our API that returns a JSON-formatted response. Then the app is opened up through the server port specified in the config.

We can now boot up our app and see what we have achieved so far. Run npm run dev in the api/ folder and then go to http://localhost:3001. You should see something like this:

First Look At Our API

Handling file uploads has a lot of edge cases and security concerns, so it’s not a very good idea to build it from scratch. We will use an npm package called Multer that makes it super easy. Install the package by running npm i --save multer, and then make the following changes in the src/index.js file:

import http from 'http';
import cors from 'cors';
import multer from 'multer';
import { resolve } from 'path';

//previously written code here

const config = {
    port: 3001,
    uploadDir: `${resolve(__dirname, '..')}/uploads/`,
    database: {
        username: "root",
        password: "admin",
        host: "localhost",
        port: "3306",
        dialect: "mysql",
        database: "photato",
    }
};

//previously written code here

// connect to db
const database = new Sequelize(config.database);

// setup multer
const uploadMiddleware = multer({ 
    dest: config.uploadDir,
    fileFilter: function (req, file, cb) {
        if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
            return cb(new Error('Only image files are allowed!'));
        }
        cb(null, true);
    }, 
}).single('photo');

//previously written code here

    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.post('/photo', uploadMiddleware, async (req, res) => {
        try {
            const photo = await Photo.create(req.file);
            res.json({success: true, photo});
        } catch (err) {
            res.status(422).json({success: false, message: err.message});
        }
    });

//previously written code here
Enter fullscreen mode Exit fullscreen mode

Overview of the additions:

  • Imports Multer package
  • Adds the destination directory where the uploaded files will be stored. Right now, it’s set to be api/upload/, which doesn’t exist yet. So let’s create the folder as well: mkdir upload
  • Initializes the Multer middleware that accepts a single file with the key photo and saves the file in the specified folder
  • Only allows image files to be uploaded through Multer
  • Adds a new POST request endpoint that uses the upload middleware. Once the file is handled by the middleware, it attaches the file info, such as destination path, size, mimetype etc., to the Express req object that is passed to the next handler. In this case, the next handler tries to save the file details in the database (we will discuss this soon), and on success, it returns a JSON response including the file details, and on failure, it returns a JSON response with the error message

This line const photo = await Photo.create(req.file);, however, needs a bit more explanation. ModelName.create(modelData) is how you create a new row in a database table through Sequelize, and in the above code, we expect a Sequelize model named Photo to exist, which we haven’t created yet. Let’s fix that by running touch src/photo.model.js and putting the following code in that file:

import { Model, DataTypes } from 'sequelize';

const PhotoSchema = {
    originalname: {
        type: DataTypes.STRING,
        allowNull: false,
    },
    mimetype: {
        type: DataTypes.STRING,
        allowNull: false,
    },
    size: {
        type: DataTypes.INTEGER,
        allowNull: false,
    },
    filename: {
        type: DataTypes.STRING,
        allowNull: false,
    },
    path: {
        type: DataTypes.STRING,
        allowNull: false,
    },
};

class PhotoModel extends Model {
    static init (sequelize) {
        return super.init(PhotoSchema, { sequelize });
    }
};

export default PhotoModel;
Enter fullscreen mode Exit fullscreen mode

That’s a lot of code, but the gist of it is that we are creating a Sequelize model class with a schema definition where the fields (table columns) are all strings (translates to VARCHAR in MySQL) except for the size field, which is an integer. The schema looks like this because after handling uploaded files, Multer provides exactly that data and attaches it to req.file.

Going back to how this model can be used in our route handler, we need to connect the model with MySQL through Sequelize. In our src/index.js file, add the following lines:

// previously written code
import { Sequelize } from 'sequelize';
import PhotoModel from './photo.model';

// previously written code

// connect to db
const database = new Sequelize(config.database);

// initialize models
const Photo = PhotoModel.init(database);

// previously written code
Enter fullscreen mode Exit fullscreen mode

So now that we have pieced together the missing case of the Photo, let’s add one more endpoint to our API and see one more use of the model:

// previously written code

    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.get('/photo', async (req, res) => {
        const photos = await Photo.findAndCountAll();
        res.json({success: true, photos});
    });

// previously written code
Enter fullscreen mode Exit fullscreen mode

This adds a GET request handler at the /photo path and returns a JSON response containing all the previously uploaded photos. Notice that Photo.findAndCountAll() returns an object that looks like this:

{
    count: <number of entries in the model/table>,
    rows: [
        {<object containing column data from the table>},
        {<object containing column data from the table>},
        ....
    ]
}
Enter fullscreen mode Exit fullscreen mode

With all the above changes, your src/index.js file should look like this:

import http from 'http';
import cors from 'cors';
import multer from 'multer';
import express from 'express';
import { resolve } from 'path';
import { Sequelize } from 'sequelize';

import PhotoModel from './photo.model';

const config = {
    port: 3001,
    uploadDir: `${resolve(__dirname, '..')}/uploads/`,
    database: {
        username: "root",
        password: "admin",
        host: "localhost",
        port: "3306",
        dialect: "mysql",
        database: "photato",
    }
};

let app = express();
app.server = http.createServer(app);

// 3rd party middlewares
app.use(cors({}));

// connect to db
const database = new Sequelize(config.database);

// initialize models
const Photo = PhotoModel.init(database);

// setup multer
const uploadMiddleware = multer({ 
    dest: config.uploadDir,
    fileFilter: function (req, file, cb) {
        if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
            return cb(new Error('Only image files are allowed!'));
        }
        cb(null, true);
    },
}).single('photo');

database.sync().then(() => {
    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.get('/photo', async (req, res) => {
        const photos = await Photo.findAndCountAll();
        res.json({success: true, photos});
    });

    app.post('/photo', uploadMiddleware, async (req, res) => {
        try {
            const photo = await Photo.create(req.file);
            res.json({success: true, photo});
        } catch (err) {
            res.status(400).json({success: false, message: err.message});
        }
    });

    app.server.listen(process.env.PORT || config.port, () => {
        console.log(`Started on port ${app.server.address().port}`);
    });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

You’ve come this far, congrats! Go grab a coffee or something refreshing and get ready to cross the finish line in style.

Connect gallery with server API

At this point, we have two apps: one is a browser-based React app that runs on http://localhost:3000, and the other is a server-side Node.js app running on http://localhost:3001.

So far, however, they have been strangers to each other, living their own lives. So, naturally, the next step is to marry the two and hope that they live happily ever after!

We are going to use the browser’s Fetch API to talk to our server app from the React app. To keep our server communication encapsulated, we will create a new file:

cd ../web/
touch src/api.js
Enter fullscreen mode Exit fullscreen mode

Then let’s add the following functions in that file:

const API_URL = 'http://localhost:3001';

export async function getPhotos () {
    const response = await fetch(`${API_URL}/photo`);
    return response.json();
};

export async function uploadPhoto (file) {
    if (!file)
        return null; 

    const photoFormData = new FormData();

    photoFormData.append("photo", file);


    const response = await fetch(`${API_URL}/photo`, {
        method: 'POST',
        body: photoFormData,
    });

    return response.json();
};
Enter fullscreen mode Exit fullscreen mode

Let’s break it down:

  • We have a variable API_URL that points to the URL where our server app is available
  • getPhotos makes a GET request to the /photo endpoint of our server and parses the response as JSON before returning it
  • uploadPhoto receives a file parameter and builds a FormData object that can be used to POST the file to the /photo endpoint of our server. After sending the request, it parses the response as JSON and returns it

Let’s use these nifty little functions, shall we? Open up the src/app.container.js file and add the following new lines in it:

import React, { useState } from 'react';
// previously written code...

import { uploadPhoto } from './api';

function AppContainer() {
    const [isUploading, setIsUploading] = useState(false);


    async function handlePhotoSelect (file) {
        setIsUploading(true);
        await uploadPhoto(file);
        setIsUploading(false);
    };

    return (
            // previously written code...
            <Header
                isUploading={isUploading}
                onPhotoSelect={handlePhotoSelect}
            />
            // previously written code...
    );
}
Enter fullscreen mode Exit fullscreen mode

With the above changes, we have added state Hooks in our App component. If you’re not familiar with Hooks and states, I encourage you to read up on it, but in short, state lets you re-render your UI whenever your state value changes.

Whenever our function handlePhotoSelect is executed with a file argument, it will first change isUploading‘s value to true. Then it will pass the file data to our uploadPhoto function, and when that finishes, it will switch isUploading‘s value to false:

<Header
    isUploading={isUploading}
    onPhotoSelect={handlePhotoSelect}
/>
Enter fullscreen mode Exit fullscreen mode

Then, we pass our isUploading state as a prop to our header component — and, if you recall, when isUploading changes to true, our Upload Photo button will transition into a loading state.

The second prop onPhotoSelect gets the function handlePhotoSelect. Remember when we wrote our Header component we defined the onPhotoSelect prop but never used it? Well, let’s settle that by making the following changes in the src/header.component.js file:

// previously written code...
function Header ({
    isUploading = false, 
    onPhotoSelect,
}) {
    let hiddenInput = null;

    // previously written code...

    return (
        // previously written code...
                <Button 
                    // previously written code...
                    onClick={() => hiddenInput.click()}
                >
                    Upload Photo     
                </Button>

                <input
                    hidden
                    type='file'
                    ref={el => hiddenInput = el}
                    onChange={(e) => onPhotoSelect(e.target.files[0])}
                />
        // previously written code...
    );
};
Enter fullscreen mode Exit fullscreen mode

The above changes add a hidden file input element and store its reference in the hiddenInput variable. Whenever the Button is clicked, we trigger a click on the file input element using the reference variable.

From there on, the browser’s built-in behavior kicks in and asks the user to select a file. After the user makes a selection, the onChange event is fired, and when that happens, we call the onPhotoSelect prop function and pass the selected file as its argument.

This completes one communication channel between our front-end and back-end apps. Now, you should be able to follow the below steps and get a similar result along the way:

  1. Go to http://localhost:3000
  2. Open the developer tools and go to the Network tab
  3. Click the Upload Photo button and select an image file from your local folders.
  4. See a new POST request being sent to http://localhost:3001/photos and a JSON response coming back.

Here’s how mine looks:

First Upload Attempt

To verify that the upload worked, go into the api/uploads directory, and you should see a file there. Try uploading more photos and see if they keep showing up in that folder. This is great, right? We are actually uploading our photos through our React app and saving it with our Node.js server app.

Sadly, the last step to tie it all together is to replace those kitty cats with our uploaded photos. To do that, we need to be able to request the server for an uploaded photo and get the photo file back. Let’s do that by adding one more endpoint in the api/src/index.js file:

// previously written code...
    app.get('/', (req, res) => {
        res.json({app: 'photato'});
    });

    app.get("/photo/:filename", (req, res) => {
        res.sendFile(join(config.uploadDir, `/${req.params.filename}`));
    });
// previously written code...
Enter fullscreen mode Exit fullscreen mode

The new endpoint allows us to pass any string in place of :filename through the URL, and the server looks for a file with that name in our uploadDir and sends the file in the response. So, if we have a file named image1, we can access that file by going to http://localhost:3001/photo/image1, and going to http://localhost:3001/photo/image2 will give us the file named image2.

That was easy, right? Now back to the front end. Remember how our initial boilerplate photos variable looked like? The data that we get from the server is nothing like that, right? We will fix that first. Go back to the web/src/api.js file and make the following changes:

export async function getPhotos () {
    const response = await fetch(`${API_URL}/photo`);
    const photoData = await response.json();

    if (!photoData.success || photoData.photos.count < 1)
        return [];

    return photoData.photos.rows.map(photo => ({
        src: `${API_URL}/photo/${photo.filename}`,
        width: 1, 
        height: 1,
    }));
};
Enter fullscreen mode Exit fullscreen mode

The extra lines are just transforming our server-sent data into a format that can be passed to our PhotoGallery component. It builds the src URL from the API_URL and the filename property of each photo.

Back in the app.container.js file, we add the following changes:

import React, { useState, useEffect } from 'react';
// previously written code...

import { uploadPhoto, getPhotos } from './api';

function AppContainer() {
    const [isUploading, setIsUploading] = useState(false);
    const [photos, setPhotos] = useState([]);

    useEffect(() => {
        if (!isUploading)
            getPhotos().then(setPhotos);
    }, [isUploading]);


    // previously written code...
}
Enter fullscreen mode Exit fullscreen mode

That’s it! That’s all you need to show the uploaded photos in the image gallery. We replaced our static photos variable with a state variable and initially set it to an empty array.

The most notable thing in the above change is the useEffect function. Every time isUploading state is changed, as a side effect, React will run the first argument function in the useEffect call.

Within that function, we check if isUploading is false, meaning that a new upload is either complete or the component is loaded for the first time. For only those cases, we execute getPhotos, and the results of that function are stored in the photos state variable.

This ensures that, besides loading all the previous photos on first load, the gallery is also refreshed with the newly uploaded photo as soon as the upload is complete without the need to refresh the window.

This is fun, so I uploaded four consecutive photos, and this is how my photato looks now:

Multi-Upload Result

UX tidbits

While we do have a functioning app that meets all the requirements we set out to build, it could use some UX improvements. For instance, upload success/error does not trigger any feedback for the user. We will implement that by using a nifty little toast component from Chakra UI.

Let’s go back to the web/src/app.container.js:

// previously written code...
import PhotoGallery from 'react-photo-gallery';
import { useToast } from '@chakra-ui/core';
// previously written code...

    const [photos, setPhotos] = useState([]);
    const toast = useToast();

    async function handlePhotoSelect (file) {
        setIsUploading(true);

        try {
            const result = await uploadPhoto(file);
            if (!result.success)
                throw new Error("Error Uploading photo");


            toast({
                duration: 5000,
                status: "success",
                isClosable: true,
                title: "Upload Complete.",
                description: "Saved your photo on Photato!",
            });
        } catch (err) {
            toast({
                duration: 9000,
                status: "error",
                isClosable: true,
                title: "Upload Error.",
                description: "Something went wrong when uploading your photo!",
            });
        }

        setIsUploading(false);
    };
// previously written code...
Enter fullscreen mode Exit fullscreen mode

With the above changes, you should get a little green toast notification at the bottom of your screen every time you upload a new photo. Also notice that in case of error, we are calling the toast with status:"error", which will show a red toast instead of green.

This is how my success toast looks:

Success Toast In Green

The gallery is made up of thumbnails. Shouldn’t we be able to see the full image as well? That would improve the UX a lot, right? So let’s build a full-screen version of the gallery with the react-images package.

Start by running yarn add react-images within the web/ directory. Then, pop open the src/app.container.js file and add the following bits:

import React, { useState, useEffect, useCallback } from 'react';
import Carousel, { Modal, ModalGateway } from "react-images";
// previously written code...

function AppContainer() {
    const [currentImage, setCurrentImage] = useState(0);
    const [viewerIsOpen, setViewerIsOpen] = useState(false);

    const openLightbox = useCallback((event, { photo, index }) => {
        setCurrentImage(index);
        setViewerIsOpen(true);
    }, []);

    const closeLightbox = () => {
        setCurrentImage(0);
        setViewerIsOpen(false);
    };

    // previously written code...
    return (
        // previously written code...
            <PhotoGallery
                photos={photos}
                onClick={openLightbox}
            />
            <ModalGateway>
                {viewerIsOpen && (
                    <Modal onClose={closeLightbox}>
                        <Carousel
                            currentIndex={currentImage}
                            views={photos.map(x => ({
                                ...x,
                                srcset: x.srcSet,
                                caption: x.title
                            }))}
                        />
                    </Modal>
                )}
            </ModalGateway>
        // previously written code...
    );
}
Enter fullscreen mode Exit fullscreen mode

Here’s what the changes are doing:

  • Imports the necessary components from react-images to show a full-screen gallery
  • Initiates two state variables: currentImage and viewerIsOpen. We will see how they are used soon
  • Creates a memoized callback function, openLightbox, that gets triggered when the user clicks on any of the photos from the photo gallery. When executed, the function sets viewerIsOpen to true and sets the index number of the photo that was clicked
  • Another function, closeLightbox, is created that essentially closes the full-screen gallery
  • In the render method, if viewerIsOpen is true, we render the modal lightbox containing the Carousel component from the react-images lib
  • The Modal component receives the prop onClose={closeLightbox} so that the user can close the full-screen gallery
  • We pass the currentImage index number to it so it knows which photo will be shown first. In addition, we transform all the photos from the gallery and pass them to the carousel so that the user can swipe through all the photos in full-screen mode

The end result:

Full-Screen Photo Gallery

Closing remarks

What we have built throughout this journey is a complete and functional app, but there’s a lot of room for improvement. Architecture, file-folder structure, testability — all of these things should be considered for refactoring both our client- and server-side apps. I would like you to take this as homework and add unit and/or integration testing to the codebase.

Chakra UI is a promising new tool and has numerous components that are hard to cover in one post, so I highly encourage you to go through its docs to learn more.

These days, saving uploaded content on the same disk where your app is running is somewhat frowned upon. Luckily, Multer has a lot of handy third-party plugins that would allow you to upload files directly to external storage such as S3. If you ever deploy your server app on hosting services like ZEIT Now or Netlify, they will come in handy.


Editor's note: Seeing something wrong with this post? You can find the correct version here.

Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Building a photo gallery app from scratch with Chakra UI appeared first on LogRocket Blog.

Top comments (0)