Next.js Client - Youtube GIF Maker Using Next.js, Node and RabbitMQ
Tags: webdev, node, react, javascript
Published: 2021-02-15 15:17:38

Hello everyone,
This Article is the fourth part of the series Youtube GIF Maker Using Next.js, Node and RabbitMQ.

In this article we will dive into building the client side  of our Youtube to GIF converter. This Article will contain some code snippets but the whole project can be accessed [on github](https://github.com/ragrag/youtube-gif) which contains the full source code. You can also view the [app demo](http://ytgif.vercel.app/).

Please note that the code snippets will only include the minimal code required for the functionality (HTML/Code related to styling...etc is ignored)
Also note that Bulma is used for this project but you can use whatever CSS you want.

* [Functionalities](#functionalities)
* [Home Page](#home-page) 
* GIF Page
  * [Polling The GIF Conversion Job](#polling-the-gif-conversion-job)  
  * [Server Side Rendering](#server-side-rendering)  

## Functionalities
The Client Side of our app is straight forward, it has to do only two things
* Provide an interface for creating GIF Conversion requests from youtube video
* Provide a page that keeps polling the GIF conversion job and viewing generated GIF when the job is done

Lets jump straight into building the first one in the home page.

## Home Page
Minimally this page has to provide 

* Input fields containing
   * Youtube video url
   * GIF start time
   * GIF end time
* An embedded youtube player showing the selected video as well as showing a preview of the selected time range (start/end times)
* Two buttons one for previewing the current selection as well as one for submitting the current selection for generating the GIF

Lets start by creating the three needed input fields and their respective states. 
```tsx
// pages/index.tsx
import React, { useState, useMemo } from 'react';

const Home: React.FC = () => {
  const [youtubeUrl, setYoutubeUrl] = useState("");
  const [startTime, setStartTime] = useState("");
  const [endTime, setEndTime] = useState("");

  const validYoutubeUrl = useMemo(() => {
    const youtubeUrlRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
    return youtubeUrl.match(youtubeUrlRegex);
  }, [youtubeUrl]);

  return (
    <>
      <input
       className={`input ${youtubeUrl === "" ? "is-dark" : validYoutubeUrl? "is-success": "is-danger" }`}
       type="text"
       placeholder="Youtube URL, eg: https://www.youtube.com/watch?v=I-QfPUz1es8"
       value={youtubeUrl}
       onChange={(e) => {
         setYoutubeUrl(e.target.value);
       }}
      />

      <input
        className="input is-dark"
        type="number"
        placeholder="Start Second, eg: 38"
        value={startTime}
        onChange={(e) => {
         setStartTime(e.target.value);
        }}
       />

      <input
        className="input is-dark"
        type="number"
        placeholder="End Second, eg: 72"
        value={endTime}
        onChange={(e) => {
         setEndTime(e.target.value);
        }}
      />
    </>
   )
}
```
Notice that we check for the youtube url validity using Regex. This is not necessary but it is used to provide a good visual feedback as well as will be used to conditionally render the embedded youtube player later on to avoid showing an empty player (can also be ignored).

Now its time to add the embedded youtube player
We will be using the youtube player from [react-youtube](https://github.com/tjallingt/react-youtube)

```tsx
// pages/index.tsx
import React, { useState, useMemo } from 'react';
import YouTube from "react-youtube";

const Home: React.FC = () => {
  // ...code from before
  const [ytPlayer, setYtPlayer] = useState(null);
  const ytVideoId = useMemo(() => {
    return youtubeUrl.split("v=")[1]?.slice(0, 11);
   }, [youtubeUrl]);

  return (
    <>
      <div className="content">
         {validYoutubeUrl ? (
           <>
             <h3>Preview</h3>
             <YouTube
               videoId={ytVideoId}
               opts={{
                 playerVars: {
                 start: Number(startTime),
                 end: Number(endTime),
                 autoplay: 0,
                },
               }}
               onReady={(e) => {
                setYtPlayer(e.target);
               }}
             />
            </>
         ) : (
           <h4>No Youtube Video Link Selected</h4>
        )}
      </div>
    </>
   )
}
```
Notice that we initialized a state **ytPlayer** with the youtube player event target object. We will use this later to manipulate the player programmatically, specifically when we add the preview button

Now its time to add our two buttons, Preview and Generate
* **Preview**: Used to play the youtube video from the selected start/end times to give the user an idea of how the GIF will look like
* **Generate**: Used to send the actual GIF conversion request. i.e: starting the actual conversion

```tsx
// pages/index.tsx
import React, { useState } from 'react';
import axios from "axios";
import { useRouter } from "next/router";

const Home: React.FC = () => {
  // ... code from before
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const submitYoutubeVideo = async () => {
    setLoading(true);
    try {
      const response = await axios.post(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1/jobs`,
        {
          youtubeUrl,
          startTime: Number(startTime),
          endTime: Number(endTime),
        },
        {}
      );
      router.push(`/jobs/${response.data.id}`);
    } catch (err) {
      alert(err?.response?.data?.message || "Something went wrong");
    }
    setLoading(false);
  };

  return (
    <>
     <button
      className="button is-black"
      onClick={() => {
       if (ytPlayer)
         ytPlayer.loadVideoById({
           videoId: ytVideoId,
           startSeconds: Number(startTime),
           endSeconds: Number(endTime),
          });
       }}
      >
       Preview
      </button>

      <button
       className={`button is-black is-outlined ${loading ? "is-loading" : ""}`}
       onClick={submitYoutubeVideo}
       >
        Generate GIF
       </button>
    </>
   )
}
```
One takeaway here is that when the conversion request is successful, the user is redirect to the job page

### Putting it all Together
```tsx
// pages/index.tsx
import axios from "axios";
import { useRouter } from "next/router";
import React, { useMemo, useState } from "react";
import YouTube from "react-youtube";

const Home: React.FC = () => {
  const router = useRouter();

  const [youtubeUrl, setYoutubeUrl] = useState("");
  const [startTime, setStartTime] = useState("");
  const [endTime, setEndTime] = useState("");
  const [loading, setLoading] = useState(false);
  const [ytPlayer, setYtPlayer] = useState(null);

  const validYoutubeUrl = useMemo(() => {
    const youtubeUrlRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
    return youtubeUrl.match(youtubeUrlRegex);
  }, [youtubeUrl]);

  const ytVideoId = useMemo(() => {
    return youtubeUrl.split("v=")[1]?.slice(0, 11);
  }, [youtubeUrl]);

  const submitYoutubeVideo = async () => {
    setLoading(true);
    try {
      const response = await axios.post(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1/jobs`,
        {
          youtubeUrl,
          startTime: Number(startTime),
          endTime: Number(endTime),
        },
        {}
      );
      router.push(`/jobs/${response.data.id}`);
    } catch (err) {
      console.log(err);
      alert(err?.response?.data?.message || "Something went wrong");
    }
    setLoading(false);
  };
  return (
    <>
      {validYoutubeUrl ? (
        <>
          <h3>Preview</h3>
          <YouTube
            videoId={ytVideoId}
            opts={{
              playerVars: {
                start: Number(startTime),
                end: Number(endTime),
                autoplay: 0,
              },
            }}
            onReady={(e) => {
              setYtPlayer(e.target);
            }}
          />
        </>
      ) : (
        <h4>No Youtube Video Link Selected</h4>
      )}

      <input
        className={`input ${youtubeUrl === ""? "is-dark": validYoutubeUrl? "is-success": "is-danger"}`}
        type="text"
        placeholder="Youtube URL, eg: https://www.youtube.com/watch?v=I-QfPUz1es8"
        value={youtubeUrl}
        onChange={(e) => {
          setYoutubeUrl(e.target.value);
        }}
      />

      <input
        className="input is-dark"
        type="number"
        placeholder="Start Second, eg: 38"
        value={startTime}
        onChange={(e) => {
          setStartTime(e.target.value);
        }}
      />

      <input
        className="input is-dark"
        type="number"
        placeholder="End Second, eg: 72"
        value={endTime}
        onChange={(e) => {
          setEndTime(e.target.value);
        }}
      />

      <button
        className={`button is-black`}
        onClick={() => {
          if (ytPlayer)
            ytPlayer.loadVideoById({
              videoId: ytVideoId,
              startSeconds: Number(startTime),
              endSeconds: Number(endTime),
            });
        }}
      >
        Preview
      </button>

      <button
        className={`button is-black is-outlined ${loading ? "is-loading" : ""}`}
        onClick={submitYoutubeVideo}
      >
        Generate GIF
      </button>
    </>
  );
};

export default Home;
```

## GIF Page

### Polling the GIF Conversion Job

What we want to achieve here is periodically fetch the GIF Conversion Job data from the backend. This is known as **polling**.
To do this we are going to be using [swr](https://swr.vercel.app/) which is a data fetching library for React. It is not necessarily used for polling but it has a nice API that supports polling (refreshing data on an interval). Other data fetching libraries with similar capabilities exist most notably [React Query](https://react-query.tanstack.com/). You can also perform polling with axios (using timeouts) however data fetching libraries like swr and React Query provide hooks for data fetching which improves the development experience as well as provide other capabilities such as caching.

First we have to provide the data fetching function

```ts
import axios from "axios";
import Job from "../../common/interfaces/Job.interface";

export default async function fetchJobById(jobId: string): Promise<Job> {
  try {
    const response = await axios.get(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1/jobs/${jobId}`
    );

    return response.data;
  } catch (err) {
    if (err.response?.status === 404) window.location.href = "/404";
    throw err;
  }
}
```
we can then use this with swr to poll our GIF conversion job
```tsx
// pages/jobs/[id].tsx
import { useRouter } from "next/router";
import React from "react";
import useSWR from "swr";
import Job from "../../lib/common/interfaces/Job.interface";
import fetchJobById from "../../lib/requests/fetchers/jobById";

export default function JobPage() {
  const router = useRouter()
  const { jobId } = router.query
  const [jobDone, setJobDone] = React.useState(false);


  const { data: job, error: errorJob, isValidating: isValidatingJob } = useSWR(
    [`/api/jobs/${jobId}`, jobId],
    async (url, jobId) => await fetchJobById(jobId),
    {
      initialData: null,
      revalidateOnFocus: false,
  // job will be polled from the backend every 2 seconds until its status change to 'done'
      refreshInterval: jobDone ? 0 : 2000,
    }
  );

  React.useEffect(() => {
    if (job?.status === "done") setJobDone(true);
  }, [job]);

  const loadingJob = !job;

  return (
    <>
     {/* rendering logic */}
    </>
  );
}
```
Notice in that snippet that the refreshInterval is how often the data will be polled from the backend. we used a boolean state that will keep track of the job status and once it is done, we will stop polling the backend 

### Server Side Rendering
We can leverage Next's server side rendering to dynamically get the id from the url as well as initially fetch the job once before the page loads.
To do this we will use **getServerSideProps()**
See [Next.js Docs](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) for more info about this

```tsx
// pages/jobs/[id].tsx
// ...other imports
import { InferGetServerSidePropsType } from "next";

export const getServerSideProps = async (context) => {
  const jobId = context.params.id;
  try {
    const initialJob: Job = await fetchJobById(jobId);
    return { props: { jobId, initialJob: initialJob } };
  } catch (err) {
    return { props: { jobId, initialJob: null } };
  }
};


export default function JobPage({
  jobId,
  initialJob,
}: InferGetServerSidePropsType<typeof getServerSideProps>)  {
  //...other code
  const { data: job, error: errorJob, isValidating: isValidatingJob } = useSWR(
    [`/api/jobs/${jobId}`, jobId],
    async (url, jobId) => await fetchJobById(jobId),
    {
      // use initialJob instead of null
      initialData: initialJob,
      revalidateOnFocus: false,
      refreshInterval: jobDone ? 0 : 2000,
    }
  );

  return (
    <>
     {/* rendering logic */}
    </>
  );
}
```
Notice that we used initialJob in the initialData property in swr options

### Putting It All Together

```tsx
// pages/jobs/[id].tsx
import { InferGetServerSidePropsType } from "next";
import React from "react";
import useSWR from "swr";
import Job from "../../lib/common/interfaces/Job.interface";
import fetchJobById from "../../lib/requests/fetchers/jobById";

export default function JobPage({
  jobId,
  initialJob,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const [jobDone, setJobDone] = React.useState(false);

  const { data: job, error: errorJob, isValidating: isValidatingJob } = useSWR(
    [`/api/jobs/${jobId}`, jobId],
    async (url, jobId) => await fetchJobById(jobId),
    {
      initialData: initialJob,
      revalidateOnFocus: false,
      refreshInterval: jobDone ? 0 : 2000,
    }
  );

  React.useEffect(() => {
    if (job?.status === "done") setJobDone(true);
  }, [job]);

  const loadingJob = !job;

  return (
    <>
      {loadingJob ? (
        <>
          <h4>Getting conversion status..</h4>
          <progress className="progress is-medium is-dark" max="100">
            45%
          </progress>
        </>
      ) : (
        <div className="content">
          {job.status === "error" ? (
            <h4 style={{ color: "#FF0000" }}>Conversion Failed</h4>
          ) : job.status === "done" ? (
            <>
              {!job.gifUrl ? (
                <h4 style={{ color: "#FF0000" }}>Conversion Failed</h4>
              ) : (
                <>
                  <h4>Gif</h4>
                  <img src={job.gifUrl}></img>
                  <h6>
                    GIF Url : <a href={job.gifUrl}>{job.gifUrl}</a>
                  </h6>
                  <h6>
                    Converted from :
                    <a href={job.youtubeUrl}>{job.youtubeUrl}</a>
                  </h6>
                </>
              )}
            </>
          ) : (
            <>
              <h4>Working..</h4>
              <h5>Conversion Status : {job.status}</h5>
              <progress className="progress is-medium is-dark" max="100">
                45%
              </progress>
            </>
          )}
        </div>
      )}
    </>
  );
}

export const getServerSideProps = async (context) => {
  const jobId = context.params.id;
  try {
    const initialJob: Job = await fetchJobById(jobId);
    return { props: { jobId, initialJob: initialJob } };
  } catch (err) {
    return { props: { jobId, initialJob: null } };
  }
};

```

This was the last part of our series! Hopefully you learned something new and remember that the full source code can be viewed on the [github repository](https://github.com/ragrag/youtube-gif)