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);
}}
/>
</>
)
}
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>
</>
)
}
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>
</>
)
}
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;
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;
}
}
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 */}
</>
);
}
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 */}
</>
);
}
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 } };
}
};
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
Top comments (5)
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/
Thanks man, i just did on all the articles. your tool is very handy!
awesome!
Thank you so much for these posts!
I have a small question, how do you create the sequence and flow diagrams?
Thank you,
i use draw.io for all the diagram sketching.