YouTube Download App Part 2 Nextjs

Published on

Might implement Authentication at some point, not sure how much traffic this app will generate, I have shared it with some of my colleges.

#
Demo Site and api

Demo Site

#
Nextjs Frontend

to use app router create-next-app@latest, select tailwindcss

#
clean up globals.css, leave only tailwind setting

globals.css
@tailwind base; @tailwind components; @tailwind utilities;

#
State Management, createContext, useContext

In this particular app, I'm using a lot of context state, there might be a better way of handling state management.

src/context/GeneralContextProvider.tsx
"use client"; import React, { createContext, Dispatch, SetStateAction, useState, } from "react"; export const generalContext = createContext<{ url: string; format: string; downloadLink: string; setUrl: Dispatch<SetStateAction<string>>; setFormat: Dispatch<SetStateAction<string>>; setDownloadLink: Dispatch<SetStateAction<string>>; allowSubmit: boolean; setAllowSubmit: Dispatch<SetStateAction<boolean>>; fileName: string; setFileName: Dispatch<SetStateAction<string>>; isInit: boolean; setIsInit: Dispatch<SetStateAction<boolean>>; submitted: boolean; setSubmitted: Dispatch<SetStateAction<boolean>>; }>({} as any); export default function GeneralContextProvider({ children, }: { children: React.ReactNode; }) { const [url, setUrl] = useState(""); const [format, setFormat] = useState("MP3"); const [downloadLink, setDownloadLink] = useState(""); const [fileName, setFileName] = useState(""); const [allowSubmit, setAllowSubmit] = useState(false); const [isInit, setIsInit] = useState(true); const [submitted, setSubmitted] = useState(false); return ( <generalContext.Provider value={{ url, setUrl, format, setFormat, downloadLink, setDownloadLink, allowSubmit, setAllowSubmit, fileName, setFileName, isInit, setIsInit, submitted, setSubmitted, }} > {children} </generalContext.Provider> ); }

#
Layout

src/app/layout.tsx
import "./globals.css"; import { Inter } from "next/font/google"; import GeneralContextProvider from "@/context/GeneralContextProvider"; const inter = Inter({ subsets: ["latin"] }); export const metadata = { title: "Youtube Download", description: "NextJS & Flask API", }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <GeneralContextProvider> <body className={`h-screen w-full flex flex-col items-center justify-center ${inter.className}`} > {children} </body> </GeneralContextProvider> </html> ); }

#
Page

Icon svgs are from online resources, converted to react components using SVGR.com

src/app/page.tsx
import UrlInput from "@/components/UrlInput"; import FormatInput from "@/components/FormatInput"; import SendRequest from "@/components/SendRequest"; import Download from "@/components/Download"; import PageTitle from "@/components/PageTitle"; export default function Page() { return ( <main className="relative items-center justify-center shadow-xl border rounded-md w-80 h-44 py-2 mx-auto bg-zinc-500 text-zinc-50 flex flex-col"> <PageTitle /> <UrlInput /> <FormatInput /> <div className="flex-row flex gap-x-4"> <SendRequest /> <Download /> </div> </main> ); }

#
Icon svgs

src/components/Icon.tsx
import * as React from "react"; export const LoadingIcon = ({ className = "", ...rest }: { className: string; }) => ( <svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} {...rest} className={`${className}`} > <style>{"@keyframes spinner_AtaB{to{transform:rotate(360deg)}}"}</style> <path d="M12 1a11 11 0 1 0 11 11A11 11 0 0 0 12 1Zm0 19a8 8 0 1 1 8-8 8 8 0 0 1-8 8Z" opacity={0.25} /> <path d="M10.14 1.16a11 11 0 0 0-9 8.92A1.59 1.59 0 0 0 2.46 12a1.52 1.52 0 0 0 1.65-1.3 8 8 0 0 1 6.66-6.61A1.42 1.42 0 0 0 12 2.69a1.57 1.57 0 0 0-1.86-1.53Z" style={{ transformOrigin: "center", animation: "spinner_AtaB .75s infinite linear", }} /> </svg> ); export const DownloadIcon = ({ className = "", ...rest }: { className: string; }) => ( <svg xmlns="http://www.w3.org/2000/svg" width={800} height={800} viewBox="0 0 24 24" {...rest} className={`${className}`} > <title /> <g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} > <path d="M3 12.3v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> <path d="m7.9 12.3 4.1 4 4.1-4" data-name="Right" /> <path d="M12 2.7v11.5" /> </g> </svg> ); export const SendIcon = ({ className = "", ...rest }: { className: string; }) => ( <svg xmlns="http://www.w3.org/2000/svg" width={800} height={800} fill="none" viewBox="0 0 24 24" {...rest} className={`${className}`} > <path fill="currentColor" d="M20.33 3.67a1.45 1.45 0 0 0-1.47-.35L4.23 8.2A1.44 1.44 0 0 0 4 10.85l6.07 3 3 6.09a1.44 1.44 0 0 0 1.29.79h.1a1.43 1.43 0 0 0 1.26-1l4.95-14.59a1.41 1.41 0 0 0-.34-1.47ZM4.85 9.58l12.77-4.26-7.09 7.09-5.68-2.83Zm9.58 9.57-2.84-5.68 7.09-7.09-4.25 12.77Z" /> </svg> ); export const LoadingIcon2 = ({ className = "", ...rest }: { className: string; }) => ( <svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} {...rest} className={`${className}`} > <style> { "@keyframes spinner_8HQG{0%,57.14%{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}" } </style> <circle fill="currentColor" cx={4} cy={12} r={3} className="spinner_qM83" /> <circle fill="currentColor" cx={12} cy={12} r={3} className="spinner_qM83" style={{ animationDelay: ".1s", }} /> <circle fill="currentColor" cx={20} cy={12} r={3} className="spinner_qM83" style={{ animationDelay: ".2s", }} /> </svg> );

#
Page title

Might add more style optin or effect some other time.

src/components/PageTitle.tsx
export default function PageTitle() { return ( <span className="-mt-2 p-1 text-2xl font-extrabold">Youtube Download</span> ); }

#
Format input

Radio button, select MP3 or MP4

src/components/FormatInput.tsx
"use client"; import { ChangeEvent, useContext } from "react"; import { generalContext } from "@/context/GeneralContextProvider"; export default function FormatInput() { const { format, setFormat } = useContext(generalContext); const handleFormatChange = (e: ChangeEvent<HTMLInputElement>) => { setFormat(e.target.value); }; return ( <div className="flex pt-1 space-x-4 text-lg"> <label form="MP3"> <input type="radio" value="MP3" onChange={handleFormatChange} checked={format === "MP3"} /> &nbsp;MP3 </label> <label form="MP4"> <input type="radio" value="MP4" onChange={handleFormatChange} checked={format === "MP4"} /> &nbsp;MP4 </label> </div> ); }

#
Url input

Styling from tailwind css documentary example.

src/components/UrlInput.tsx
"use client"; import { useContext } from "react"; import { generalContext } from "@/context/GeneralContextProvider"; export default function UrlInput() { const { url, setUrl } = useContext(generalContext); //console.log("url change", url); return ( <div className="flex flex-col text-lg"> <input id="yt" className=" text-teal-900 placeholder:italic placeholder:text-slate-400 block w-full border-slate-300 rounded-sm shadow-sm focus:outline-none focus:border-zinc-700 focus:ring-zinc-700 focus:ring-1 text-ellipsis " type="text" name="url" placeholder=" Youtube URL" value={url} onChange={(e) => setUrl(e.target.value)} /> </div> ); }

#
Handle download

ChatGPT is quite helpful on how to implement this.

src/components/Download.tsx
"use client"; import { useContext } from "react"; import { generalContext } from "@/context/GeneralContextProvider"; import { DownloadIcon, LoadingIcon2 } from "@/components/Icon"; export default function Download() { const { downloadLink, fileName, submitted, setSubmitted } = useContext(generalContext); //console.log(downloadLink); if (downloadLink) setSubmitted(false); const handleClick = (e: React.FormEvent) => { const a = document.createElement("a"); a.href = downloadLink; a.download = fileName; a.click(); }; return ( <div className="py-1 w-12 h-12 text-lg"> <button disabled={!downloadLink} type="button" onClick={handleClick} className={` py-0.5 group bg-zinc-600 rounded-md w-full disabled:bg-zinc-300 disabled:text-zinc-500 transition-colors border disabled:border-zinc-600 border-zinc-400 border-solid hover:bg-zinc-50 hover:text-zinc-800 `} > <span className={`inline-block pt-2`}> {submitted ? ( <LoadingIcon2 className="w-6 h-6" /> ) : ( <DownloadIcon className="w-6 h-6" /> )} </span> </button> </div> ); }

#
Handle Submit, communication with server

I left some debugging-purpose-segment in, this section is not complete yet, error handling and status response handling need more work.

src/compoenents/SendRequest,tsx
"use client"; import { useContext, useEffect } from "react"; import { generalContext } from "@/context/GeneralContextProvider"; import { LoadingIcon, SendIcon } from "@/components/Icon"; export default function SendRequest() { const { url, format, setDownloadLink, allowSubmit, setAllowSubmit, fileName, setFileName, isInit, setIsInit, setSubmitted, } = useContext(generalContext); useEffect(() => { if (url === "") { setAllowSubmit(false); setIsInit(true); } else { setIsInit(false); setAllowSubmit(true); } }, [url, setAllowSubmit, setIsInit]); const handleClick = async () => { if ( url.startsWith("https://www.youtube.com/watch?v=") || url.startsWith("https://m.youtube.com/watch?v=") || url.startsWith("https://youtu.be/") ) { setSubmitted(true); setAllowSubmit(false); await fetch("https://flask.ivancetus.com", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ url, format }), }) .then((res) => { // check response headers // console.log("Response Headers:"); //@ts-ignore // for (const [name, value] of res.headers.entries()) { // console.log(`${name}: ${value}`); // } if (res.headers.get("content-type") === "audio/mpeg") { const file_name_utf8 = res.headers .get("Content-Disposition") ?.split("filename*=UTF-8''")[1]; console.log("utf8 file name: ", file_name_utf8); if (file_name_utf8) { const file_name_decoded = decodeURIComponent( file_name_utf8.split(".")[0] ); console.log( "decoded file name: ", file_name_decoded.concat(".", format.toLowerCase()) ); setFileName(file_name_decoded.concat(".", format.toLowerCase())); } res.blob().then((blob) => { setDownloadLink(URL.createObjectURL(blob)); }); } else if (!res.ok) { if (res.status.valueOf() === 500) window.alert("server error, please try again later"); if (res.status.valueOf() === 400) window.alert("video not found, make sure url is correct"); console.log(res); } }) .catch((e) => console.log(e)); } else { window.alert( "invalid url, must start with one of the following, https://www.youtube.com/... or https://m.youtube.com/... or https://youtu.be/..." ); } setIsInit(true); // mimic delay // @ts-ignore // const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // setAllowSubmit(false); // console.log("submitted"); // await delay(2000); // setAllowSubmit(true); // console.log("can submit again"); }; return ( <div className="py-1 w-12 h-12 text-lg"> <button disabled={!allowSubmit} type="button" onClick={handleClick} className={` py-0.5 group bg-zinc-600 rounded-md w-full disabled:bg-zinc-300 disabled:text-zinc-500 transition-colors border disabled:border-zinc-600 border-zinc-400 border-solid hover:bg-zinc-50 hover:text-zinc-800 `} > <span className={`inline-block pt-2`}> {isInit ? ( <SendIcon className="w-6 h-6" /> ) : ( <> {allowSubmit ? ( <SendIcon className="w-6 h-6" /> ) : ( <LoadingIcon className="" /> )} </> )} </span> </button> </div> ); }

#
run test server

next dev and test its functions

#
Deploy to Vercel, need to have a GitHub account

Share project on GitHub, pull from vercel dashboard, then deploy. API endpoint need to be https, or builds on vercel won't be able to connect.