DEV Community

loading...
Cover image for Next.js Client - Youtube GIF Maker Using Next.js, Node and RabbitMQ

Next.js Client - Youtube GIF Maker Using Next.js, Node and RabbitMQ

ragrag profile image Raggi ・7 min read

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 which contains the full source code. You can also view the app demo.

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

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.

// 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);
        }}
      />
    </>
   )
}
Enter fullscreen mode Exit fullscreen mode

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

// 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>
    </>
   )
}
Enter fullscreen mode Exit fullscreen mode

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
// 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>
    </>
   )
}
Enter fullscreen mode Exit fullscreen mode

One takeaway here is that when the conversion request is successful, the user is redirect to the job page

Putting it all Together

// 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;
Enter fullscreen mode Exit fullscreen mode

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 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. 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

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

we can then use this with swr to poll our GIF conversion job

// 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 */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 for more info about this

// 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 */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that we used initialJob in the initialData property in swr options

Putting It All Together

// 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 } };
  }
};

Enter fullscreen mode Exit fullscreen mode

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

Discussion (5)

pic
Editor guide
Collapse
tuliocalil profile image
Tulio Calil

Hi Raggi, I am loving this series! the content are awesome.
what you think add some anchors links to your summary? I make a tool that create summary of dev.to posts automatically, if you want use it you can check here summaryze-dev.vercel.app/

Collapse
ragrag profile image
Raggi Author

Thanks man, i just did on all the articles. your tool is very handy!

Collapse
tuliocalil profile image
Tulio Calil

awesome!

Collapse
yonatanhanan profile image
YonatanHanan

Thank you so much for these posts!
I have a small question, how do you create the sequence and flow diagrams?

Collapse
ragrag profile image
Raggi Author

Thank you,
i use draw.io for all the diagram sketching.