Table of Contents
How I Made My Portfolio with Next.js
Everyone needs to establish a portfolio or personal website so that potential employers can get a better sense of your work. I believe everyone should make one, even if it's very simple or doesn't have much to show. Having one is always a good idea.
We will be looking at my portfolio built with Next.js, which includes many things (which we will explore later). Without further ado, let's get started.
Initially, I didn't have a great design in mind. So I just started coding and it turned out better than I expected (trust me, I thought worse). You will see two versions of my portfolio. There is an older one and a newer one.
Older Version
Previously, I had only four main pages: Homepage (About me), Skills, Blog, and Projects. I was using Dev.to API to fetch my blogs and their data.
Homepage
There were seven sections on the old homepage: About me, Top Skills, Recent blogs, Certification, Projects, FAQ, and Contact. Rather than showing all blogs and projects, it just shows 10 cards.
Skills
The skill page had a progress bar showing how well I knew the language or skill, along with a brief description.
Blogs
There are a lot of things going on in the blog. First, here is a search bar and then the Blog sorting system. After that, you have a blog card.
When hovering over a blog card, you see two options, view and dev.to, along with a brief description of the blog.
As you click on the View Button, then it would have taken you to the Blog. Which looks like this:
Projects
In Projects, there are only three buttons through which you can just go to the GitHub link Live Project, and can share it.
This was my old portfolio. You can also share your insights about this portfolio. I look forward to hearing what you have to say.
Current Version
The current version of the portfolio has many things. We will be looking at them one by one along with how I implemented them. Lets' just build it.
Take a look at the current version of my portfolio by vising j471n.in
Setup Project
Create Project
Start by creating a new Next.js project if you don’t have one set up already. The most common approach is to use Create Next App.
terminal
npx create-next-app my-project
cd my-project
Install Tailwind CSS
Install tailwindcss
and its peer dependencies via npm, and then run the init command to generate both tailwind.config.js
and postcss.config.js
.
terminal
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Add the Tailwind directives to your CSS
Add the @tailwind
directives for each of Tailwind's layers to your ./styles/globals.css
file.
styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
Configure your template paths
Add the paths to all of your template files in your tailwind.config.js
file.
tailwind.config.js
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"./layout/*.{js,jsx,ts,tsx}",
],
darkMode: "class", // to setup dark mode
theme: {
// Adding New Fonts
fontFamily: {
inter: ["Inter", "sans-serif"],
sarina: ["Sarina", "cursive"],
barlow: ["Barlow", "sans-serif"],
mono: ["monospace"],
},
extend: {
colors: {
darkPrimary: "#181A1B",
darkSecondary: "#25282A",
darkWhite: "#f2f5fa",
},
listStyleType: {
square: "square",
roman: "upper-roman",
},
animation: {
wiggle: "wiggle 1s ease-in-out infinite",
"photo-spin": "photo-spin 2s 1 linear forwards",
},
keyframes: {
wiggle: {
"0%, 100%": { transform: "rotate(-3deg)" },
"50%": { transform: "rotate(3deg)" },
},
"photo-spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
},
screens: {
// Custom Screen styles
"3xl": "2000px",
xs: "480px",
},
},
// Adding Tailwind Plugins
plugins: [
require("@tailwindcss/line-clamp"),
require("@tailwindcss/typography"),
require("tailwind-scrollbar-hide"),
],
},
};
I am going to use pnpm as the package manager instead of npm. You can use npm or yarn according to your preference.
Now we need to install the tailwindcss plugins that we just added to tailwind.config.js
terminal
pnpm install @tailwindcss/line-clamp @tailwindcss/typography tailwind-scrollbar-hide
@tailwindcss/typography
will be used in styling the blog.
Start the server
Run your build process with pnpm run dev
. And your project will be available at http://localhost:3000
terminal
npm run dev
Setup next.config.js
Installing @next/bundle-analyzer
terminal
pnpm i @next/bundle-analyzer next-pwa
- next-pwa: To generate PWA which we will cover later
- @next/bundle-analyzer: Analyze your bundle (optional)
next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
const withPWA = require("next-pwa");
module.exports = withBundleAnalyzer(
withPWA({
webpack: true,
webpack: (config) => {
// Fixes npm packages that depend on `fs` module
config.resolve.fallback = { fs: false };
return config;
},
reactStrictMode: true,
images: {
domains: [
"cdn.buymeacoffee.com",
"res.cloudinary.com",
"imgur.com",
"i.imgur.com",
"cutt.ly",
"activity-graph.herokuapp.com",
"i.scdn.co", // images from spotify
"images.unsplash.com",
],
},
// Pwa Setting
pwa: {
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
publicExcludes: ["!resume.pdf"], // don't cache pdf which I'll add later
},
})
);
Setup jsconfig.json
Setting up jsconfig.json
to make import easier. You don't need to use ../../../..
anymore, you can just use @
to refer to any folder.
jsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@utils/*": ["utils/*"],
"@content/*": ["content/*"],
"@styles/*": ["styles/*"],
"@context/*": ["context/*"],
"@layout/*": ["layout/*"],
"@hooks/*": ["hooks/*"]
}
}
}
Disabling some ESLint rules
.eslintrc.json
{
"extends": "next/core-web-vitals",
"rules": {
"react/no-unescaped-entities": 0,
"@next/next/no-img-element": "off"
}
}
Adding DarkMode Support
This is just to create a Dark mode Support using Context API:
context/darkModeContext.js
import React, { useState, useContext, useEffect, createContext } from "react";
const DarkModeContext = createContext(undefined);
export function DarkModeProvider({ children }) {
const [isDarkMode, setDarkMode] = useState(false);
function updateTheme() {
const currentTheme = localStorage.getItem("isDarkMode") || "false";
if (currentTheme === "true") {
document.body.classList.add("dark");
setDarkMode(true);
} else {
document.body.classList.remove("dark");
setDarkMode(false);
}
}
useEffect(() => {
updateTheme();
}, []);
function changeDarkMode(value) {
localStorage.setItem("isDarkMode", value.toString());
// setDarkMode(value);
updateTheme();
}
return (
<DarkModeContext.Provider value={{ isDarkMode, changeDarkMode }}>
{children}
</DarkModeContext.Provider>
);
}
export const useDarkMode = () => {
const context = useContext(DarkModeContext);
if (context === undefined) {
throw new Error("useAuth can only be used inside AuthProvider");
}
return context;
};
Now Wrap the _app.js
with DarkModeProvider
:
_app.js
import "@styles/globals.css";
import { DarkModeProvider } from "@context/darkModeContext";
function MyApp({ Component, pageProps }) {
return (
{/* Adding Darkmode Provider */}
<DarkModeProvider>
<Component {...pageProps} />
</DarkModeProvider>
);
}
Creating Layout
layout/Layout.js
import { useState } from "react";
import TopNavbar from "../components/TopNavbar";
import ScrollToTopButton from "../components/ScrollToTopButton";
import Footer from "../components/Footer";
import QRCodeContainer from "@components/QRCodeContainer";
export default function Layout({ children }) {
const [showQR, setShowQR] = useState(false);
return (
<>
<TopNavbar />
<main>{children}</main>
<Footer setShowQR={setShowQR} showQR={showQR} />
<ScrollToTopButton />
<QRCodeContainer showQR={showQR} setShowQR={setShowQR} />
</>
);
}
Don't worry we will create all of them one by one:
Creating Navbar
This is what we are going to create:
Before we build Navbar we need to install some dependencies first:
- react-icons: To use icons in the project
- framer-motion : To Add animation in project
terminal
pnpm install react-icons framer-motion
Let's set up routes first. I am using a static Array where I can add a new route and it will automatically be visible to the navbar:
utils/utils.js
export const navigationRoutes = [
"home",
"about",
"stats",
"utilities",
"blogs",
"certificates",
"projects",
"newsletter",
"rss",
];
These are the routes we are going to create. Let's look at the TopNavbar
component now:
components/TopNavbar.js
/* Importing modules */
import React, { useEffect, useState, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { motion, useAnimation, AnimatePresence } from "framer-motion";
import {
FadeContainer,
hamFastFadeContainer,
mobileNavItemSideways,
popUp,
} from "../content/FramerMotionVariants";
import { useDarkMode } from "../context/darkModeContext";
import { navigationRoutes } from "../utils/utils";
import { FiMoon, FiSun } from "react-icons/fi";
We imported many things, but we have not created FramerMotionVariants
yet. So let's create them:
content/FramerMotionVariants.js
export const FadeContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { delayChildren: 0, staggerChildren: 0.1 },
},
};
export const hamFastFadeContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
delayChildren: 0,
staggerChildren: 0.1,
},
},
};
export const mobileNavItemSideways = {
hidden: { x: -40, opacity: 0 },
visible: {
x: 0,
opacity: 1,
},
};
export const popUp = {
hidden: { scale: 0, opacity: 0 },
visible: {
opacity: 1,
scale: 1,
},
transition: {
type: "spring",
},
};
These are the variants for Navbar Animation for desktop and mobile devices as well.
Now, back to the TopNavbar
:
components/TopNavbar.js
/* Importing Modules */
import React, { useEffect, useState, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { motion, useAnimation, AnimatePresence } from "framer-motion";
import {
FadeContainer,
hamFastFadeContainer,
mobileNavItemSideways,
popUp,
} from "../content/FramerMotionVariants";
import { useDarkMode } from "../context/darkModeContext";
import { navigationRoutes } from "../utils/utils";
import { FiMoon, FiSun } from "react-icons/fi";
/* TopNavbar Component */
export default function TopNavbar() {
const router = useRouter();
const navRef = useRef(null);
/* Using to control animation as I'll show the name to the mobile navbar when you scroll a bit
* demo: https://i.imgur.com/5LKI5DY.gif
*/
const control = useAnimation();
const [navOpen, setNavOpen] = useState(false);
const { isDarkMode, changeDarkMode } = useDarkMode();
// Adding Shadow, backdrop to the navbar as user scroll the screen
const addShadowToNavbar = useCallback(() => {
if (window.pageYOffset > 10) {
navRef.current.classList.add(
...[
"shadow",
"backdrop-blur-xl",
"bg-white/70",
"dark:bg-darkSecondary",
]
);
control.start("visible");
} else {
navRef.current.classList.remove(
...[
"shadow",
"backdrop-blur-xl",
"bg-white/70",
"dark:bg-darkSecondary",
]
);
control.start("hidden");
}
}, [control]);
useEffect(() => {
window.addEventListener("scroll", addShadowToNavbar);
return () => {
window.removeEventListener("scroll", addShadowToNavbar);
};
}, [addShadowToNavbar]);
// to lock the scroll when mobile is open
function lockScroll() {
const root = document.getElementsByTagName("html")[0];
root.classList.toggle("lock-scroll"); // class is define in the global.css
}
/* To Lock the Scroll when user visit the mobile nav page */
function handleClick() {
lockScroll();
setNavOpen(!navOpen);
}
return (
<div
className="fixed w-full dark:text-white top-0 flex items-center justify-between px-4 py-[10px] sm:p-4 sm:px-6 z-50 print:hidden"
ref={navRef}
>
{/* Mobile Navigation Hamburger and MobileMenu */}
<HamBurger open={navOpen} handleClick={handleClick} />
<AnimatePresence>
{navOpen && (
<MobileMenu links={navigationRoutes} handleClick={handleClick} />
)}
</AnimatePresence>
<Link href="/" passHref>
<div className="flex gap-2 items-center cursor-pointer z-50">
<motion.a
initial="hidden"
animate="visible"
variants={popUp}
className="relative hidden sm:inline-flex mr-3"
>
<h1 className="font-sarina text-xl">JS</h1>
</motion.a>
<motion.p
initial="hidden"
animate={control}
variants={{
hidden: { opacity: 0, scale: 1, display: "none" },
visible: { opacity: 1, scale: 1, display: "inline-flex" },
}}
className="absolute sm:!hidden w-fit left-0 right-0 mx-auto flex justify-center text-base font-sarina"
>
Jatin Sharma
</motion.p>
</div>
</Link>
{/* Top Nav list */}
<motion.nav className="hidden sm:flex z-10 md:absolute md:inset-0 md:justify-center">
<motion.div
initial="hidden"
animate="visible"
variants={FadeContainer}
className="flex items-center md:gap-2"
>
{navigationRoutes.slice(0, 7).map((link, index) => {
return (
<NavItem
key={index}
href={`/${link}`}
text={link}
router={router}
/>
);
})}
</motion.div>
</motion.nav>
{/* DarkMode Container */}
<motion.div
initial="hidden"
animate="visible"
variants={popUp}
className="cursor-pointer rounded-full z-30 transition active:scale-75"
title="Toggle Theme"
onClick={() => changeDarkMode(!isDarkMode)}
>
{isDarkMode ? (
<FiMoon className="h-6 w-6 sm:h-7 sm:w-7 select-none transition active:scale-75" />
) : (
<FiSun className="h-6 w-6 sm:h-7 sm:w-7 select-none transition active:scale-75" />
)}
</motion.div>
</div>
);
}
// NavItem Container
function NavItem({ href, text, router }) {
const isActive = router.asPath === (href === "/home" ? "/" : href);
return (
<Link href={href === "/home" ? "/" : href} passHref>
<motion.a
variants={popUp}
className={`${
isActive
? "font-bold text-gray-800 dark:text-gray-100"
: " text-gray-600 dark:text-gray-300"
} sm:inline-block transition-all text-[17px] hidden px-2 md:px-3 py-[3px] hover:bg-gray-100 dark:hover:bg-neutral-700/50 rounded-md`}
>
<span className="capitalize">{text}</span>
</motion.a>
</Link>
);
}
// Hamburger Button
function HamBurger({ open, handleClick }) {
return (
<motion.div
style={{ zIndex: 1000 }}
initial="hidden"
animate="visible"
variants={popUp}
className="sm:hidden"
>
{!open ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 cursor-pointer select-none transform duration-300 rounded-md active:scale-50"
onClick={handleClick}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 cursor-pointer select-none transform duration-300 rounded-md active:scale-50"
onClick={handleClick}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
</motion.div>
);
}
// Mobile navigation menu
const MobileMenu = ({ links, handleClick }) => {
return (
<motion.div
className="absolute font-normal bg-white dark:bg-darkPrimary w-screen h-screen top-0 left-0 z-10 sm:hidden"
variants={hamFastFadeContainer}
initial="hidden"
animate="visible"
exit="hidden"
>
<motion.nav className="mt-28 mx-8 flex flex-col">
{links.map((link, index) => {
const navlink =
link.toLowerCase() === "home" ? "/" : `/${link.toLowerCase()}`;
return (
<Link href={navlink} key={`mobileNav-${index}`} passHref>
<motion.a
href={navlink}
className="border-b border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100 font-semibold flex w-auto py-4 capitalize text-base cursor-pointer"
variants={mobileNavItemSideways}
onClick={handleClick}
>
{link === "rss" ? link.toUpperCase() : link}
</motion.a>
</Link>
);
})}
</motion.nav>
</motion.div>
);
};
- This is the whole file for the
TopNavbar
I used. Where I added ascroll
event to the window just to add shadow to Navbar and show my name if you are on a mobile device. - I also locked the scroll by calling the
lockScroll
function when Mobile Navbar is active/open. So that user won't be able to scroll. - I am only showing only 7 navigation routes (
navigationRoutes.slice(0, 7)
) so that navbar looks simple and clean. I'll add the remaining in the footer section. - There is also a button on the right side of the navigation bar that switches from dark mode to light mode.
Results
Now when you'll scroll , then the name will be shown on mobile devices, this is because of that control
we added earlier:
After doing that when you open mobile navigation it will animate like this:
TopNavbar
have four components:
- TopNavbar: Main Navigation Panel
- Hamburger: Top-left hamburger button
- MobileMenu: Mobile Navigation list or menu
- NavItem: Navigation link/item for desktop mode
We have already added TopNavbar
to Layout
Component.
Adding Scroll to top Button
components/ScrollToTopButton.js
import { IoIosArrowUp } from "react-icons/io";
import { useEffect, useState } from "react";
import useScrollPercentage from "@hooks/useScrollPercentage";
export default function ScrollToTopButton() {
const [showButton, setShowButton] = useState(false);
const scrollPercentage = useScrollPercentage();
useEffect(() => {
if (scrollPercentage < 95 && scrollPercentage > 10) {
setShowButton(true);
} else {
setShowButton(false);
}
}, [scrollPercentage]);
// This function will scroll the window to the top
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth", // for smoothly scrolling
});
};
return (
<>
{showButton && (
<button
onClick={scrollToTop}
aria-label="Scroll To Top"
className="fixed bottom-20 right-8 md:bottom-[50px] md:right-[20px] z-40 print:hidden"
>
<IoIosArrowUp className="bg-black dark:bg-gray-200 dark:text-darkPrimary text-white rounded-lg shadow-lg text-[45px] md:mr-10" />
</button>
)}
</>
);
}
ScrollToTopButton
won't show when you are at the top or at the bottom of the page. It will only show when your viewport is somewhere between 10%-95%
. After doing this it will look something like this:
I have also made an article with an explanation you can read that too:
Scroll to the top with JS
Creating Footer
The footer could be a little bit complex as it uses Spotify API
. This is what we are going to build:
Spotify Integration shows if I am currently playing any song or not. The Footer changed on that basis. The following image shows both states. Which we will create in a moment.
There is a QR Code Button at the bottom of the Footer which I'll cover in the next section.
creating components/Footer.js
:
components/Footer.js
import Link from "next/link";
import Image from "next/image";
import { FadeContainer, popUp } from "../content/FramerMotionVariants";
import { navigationRoutes } from "../utils/utils";
import { motion } from "framer-motion";
import { SiSpotify } from "react-icons/si";
import { HiOutlineQrcode } from "react-icons/hi";
import useSWR from "swr"; // not installed yet
// Not create yet
import fetcher from "../lib/fetcher";
import socialMedia from "../content/socialMedia";
Installing SWR
First, we need to install swr. SWR is a strategy to first return the data from the cache (stale), then send the fetch request (revalidate), and finally, come up with up-to-date data.
terminal
pnpm i swr
SWR uses the fetcher
function which is just a wrapper of the native fetch
. You can visit their docs
Creating fetcher
function
lib/fetcher.js
export default async function fetcher(url) {
return fetch(url).then((r) => r.json());
}
Creating Social Media Links
As you can see in the footer we have social media links as well which have not been created yet. Let's do it then:
content/socialMedia.js
import { AiOutlineInstagram, AiOutlineTwitter } from "react-icons/ai";
import { BsFacebook, BsGithub, BsLinkedin } from "react-icons/bs";
import { FaDev } from "react-icons/fa";
import { HiMail } from "react-icons/hi";
import { SiCodepen } from "react-icons/si";
export default [
{
title: "Twitter",
Icon: AiOutlineTwitter,
url: "https://twitter.com/intent/follow?screen_name=j471n_",
},
{
title: "LinkedIn",
Icon: BsLinkedin,
url: "https://www.linkedin.com/in/j471n/",
},
{
title: "Github",
Icon: BsGithub,
url: "https://github.com/j471n",
},
{
title: "Instagram",
Icon: AiOutlineInstagram,
url: "https://www.instagram.com/j471n_",
},
{
title: "Dev.to",
Icon: FaDev,
url: "https://dev.to/j471n",
},
{
title: "Codepen",
Icon: SiCodepen,
url: "https://codepen.io/j471n",
},
{
title: "Facebook",
Icon: BsFacebook,
url: "https://www.facebook.com/ja7in/",
},
{
title: "Mail",
Icon: HiMail,
url: "mailto:jatinsharma8669@gmail.com",
},
];
I am importing react icons as well , maybe you want to add Icons along with the text. It's up to you if you don't want then you can remove the icon part.
Now we have done almost everything, let's continue with Footer.js
:
components/Footer.js
/*......previous code......*/
export default function Footer({ setShowQR, showQR }) {}
Taking setShowQR
and showQR
as props to trigger the bottom QR Code button at the bottom.
components/Footer.js
/*......previous code......*/
export default function Footer({ setShowQR, showQR }) {
const { data: currentSong } = useSWR("/api/now-playing", fetcher);
}
Now we will use useSWR
to fetch the Next.js API route that will return the currently playing song if the user is playing otherwise simple false
API Response
// when not playing
{
"isPlaying": false
}
// when playing
{
"album": "See You Again (feat. Charlie Puth)",
"albumImageUrl": "https://i.scdn.co/image/ab67616d0000b2734e5df11b17b2727da2b718d8",
"artist": "Wiz Khalifa, Charlie Puth",
"isPlaying": true,
"songUrl": "https://open.spotify.com/track/2JzZzZUQj3Qff7wapcbKjc",
"title": "See You Again (feat. Charlie Puth)"
}
It will return with which song I am playing along with details. But we have not created /api/now-playing
route. Did we? Let's create it then.
Adding Now Playing Spotify Route
If you are not familiar with Nextjs API routes then I'll highly recommend you to look at the docs first.
Create pages/api/now-playing.js
file:
pages/api/now-playing.js
export default async function handler(req, res) {}
Now the most complex part is Spotify integration. But don't worry about that I have a blog on that in detail about how you can implement that so you can read that here (this is a must):
How to use Spotify API with Next.js
After implementing Spotify API integration you will have three things:
- SPOTIFY_CLIENT_ID=
- SPOTIFY_CLIENT_SECRET=
- SPOTIFY_REFRESH_TOKEN=
Add these to your .env.local
along with their values and restart the server.
Now we will create a function that will fetch the data on the server.
Create lib/spotify.js
:
lib/spotify.js
/* This function use "refresh_token" to get the "access_token" that will be later use for authorization */
const getAccessToken = async () => {
const refresh_token = process.env.SPOTIFY_REFRESH_TOKEN;
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(
`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`
).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
}),
});
return response.json();
};
/* Uses "access_token" to get the current playing song */
export const currentlyPlayingSong = async () => {
const { access_token } = await getAccessToken();
return fetch("https://api.spotify.com/v1/me/player/currently-playing", {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
};
In the above code we just added two functions:
- getAccessToken: This will use the
refresh_token
and return theaccess_token
to authorization for other API endpoints. - currentlyPlayingSong: This is fetch the current playing song and returns the JSON data.
You can learn more about Spotify Authorization in their documentation.
Now, we have created a function to fetch the data. Let's call it in our API Routes.
pages/api/now-playing.js
import { currentlyPlayingSong } from "@lib/spotify";
export default async function handler(req, res) {
const response = await currentlyPlayingSong();
if (response.status === 204 || response.status > 400) {
return res.status(200).json({ isPlaying: false });
}
const song = await response.json();
/* Extracting the main info that we need */
const isPlaying = song.is_playing;
const title = song.item.name;
const artist = song.item.artists.map((_artist) => _artist.name).join(", ");
const album = song.item.album.name;
const albumImageUrl = song.item.album.images[0].url;
const songUrl = song.item.external_urls.spotify;
/* Return the data as JSON */
return res.status(200).json({
album,
albumImageUrl,
artist,
isPlaying,
songUrl,
title,
});
}
It was simple Right? Now, we have added the API route (/api/now-playing
). Let's call it in Footer
and fetch the currently playing song.
components/Footer.js
/*......previous code......*/
export default function Footer({ setShowQR, showQR }) {
// we just implemented this line in API Routes now it will work as expected
const { data: currentSong } = useSWR("/api/now-playing", fetcher);
}
If you go to http://localhost:3000/api/now-playing you will see some data is returned. If you didn't mess up. (If you did then try reading the about part again)
components/Footer.js
/*......previous code......*/
export default function Footer({ setShowQR, showQR }) {
const { data: currentSong } = useSWR("/api/now-playing", fetcher);
return (
<footer className=" text-gray-600 dark:text-gray-400/50 w-screen font-inter mb-20 print:hidden">
<motion.div
initial="hidden"
whileInView="visible"
variants={FadeContainer}
viewport={{ once: true }}
className="max-w-4xl 2xl:max-w-5xl 3xl:max-w-7xl p-5 border-t-2 border-gray-200 dark:border-gray-400/10 mx-auto text-sm sm:text-base flex flex-col gap-5"
>
<div>
{currentSong?.isPlaying ? (
<WhenPlaying song={currentSong} />
) : (
<NotPlaying />
)}
</div>
<section className="grid grid-cols-3 gap-10">
<div className="flex flex-col gap-4 capitalize">
{navigationRoutes.slice(0, 4).map((text, index) => {
return (
<FooterLink key={index} id={index} route={text} text={text} />
);
})}
</div>
<div className="flex flex-col gap-4 capitalize">
{navigationRoutes
.slice(4, navigationRoutes.length)
.map((route, index) => {
let text = route;
if (route === "rss") text = "RSS";
return <FooterLink key={index} route={route} text={text} />;
})}
</div>
<div className="flex flex-col gap-4 capitalize">
{socialMedia.slice(0, 4).map((platform, index) => {
return (
<Link key={index} href={platform.url} passHref>
<motion.a
className="hover:text-black dark:hover:text-white w-fit"
variants={popUp}
target="_blank"
rel="noopener noreferrer"
href={platform.url}
>
{platform.title}
</motion.a>
</Link>
);
})}
</div>
</section>
</motion.div>
<div className="w-full flex justify-center">
<div
onClick={() => setShowQR(!showQR)}
className="bg-gray-700 text-white p-4 rounded-full cursor-pointer transition-all active:scale-90 hover:scale-105"
>
<HiOutlineQrcode className="w-6 h-6 " />
</div>
</div>
</footer>
);
}
In the about Code, we have not implemented three things yet:
- FooterLink: To show the link in the footer
- NotPlaying: when I am not playing a song then show this component
- WhenPlaying: Show this component when I am playing the song
Let's add these components inside components/Footer.js
. You can make separate components for them, it's up to you. I've added those inside the Footer
components:
components/Footer.js
/*......previous code (Footer Component)......*/
function FooterLink({ route, text }) {
return (
<Link href={`/${route}`} passHref>
<motion.a
className="hover:text-black dark:hover:text-white w-fit"
variants={popUp}
href={`/${route}`}
>
{text}
</motion.a>
</Link>
);
}
function NotPlaying() {
return (
<div className="flex items-center gap-2 flex-row-reverse sm:flex-row justify-between sm:justify-start">
<SiSpotify className="w-6 h-6" />
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-3">
<div className="font-semibold md:text-lg text-black dark:text-white">
Not Playing
</div>
<span className="hidden md:inline-flex">—</span>
<p className="text-gray-500 text-xs sm:text-sm">Spotify</p>
</div>
</div>
);
}
function WhenPlaying({ song }) {
return (
<div className="flex flex-col gap-4">
<h4 className="text-lg font-semibold">Now Playing</h4>
<Link href={song.songUrl} passHref>
<a
href={song.songUrl}
className="flex items-center justify-between bg-gray-200 dark:bg-darkSecondary p-3 sm:p-4 rounded-sm"
>
<div className=" flex items-center gap-2">
<div className="w-10 h-10">
<Image
alt={song.title}
src={song.albumImageUrl}
width={40}
height={40}
layout="fixed"
quality={50}
placeholder="blur"
blurDataURL={song.albumImageUrl}
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-3">
<h3 className="font-semibold md:text-lg text-black dark:text-white animate-">
{song.title}
</h3>
<span className="hidden md:inline-flex">—</span>
<p className="text-gray-600 text-xs sm:text-sm">{song.artist}</p>
</div>
</div>
<div className="flex items-center gap-2">
<SiSpotify className="w-6 h-6 text-green-500 animate-[spin_2s_linear_infinite]" />
</div>
</a>
</Link>
</div>
);
}
If you are confused that how will it turn out , then the following is the full code of the footer:
components/Footer.js
import Link from "next/link";
import Image from "next/image";
import socialMedia from "../content/socialMedia";
import { FadeContainer, popUp } from "../content/FramerMotionVariants";
import { navigationRoutes } from "../utils/utils";
import { motion } from "framer-motion";
import { SiSpotify } from "react-icons/si";
import useSWR from "swr";
import fetcher from "../lib/fetcher";
import { HiOutlineQrcode } from "react-icons/hi";
export default function Footer({ setShowQR, showQR }) {
const { data: currentSong } = useSWR("/api/now-playing", fetcher);
return (
<footer className=" text-gray-600 dark:text-gray-400/50 w-screen font-inter mb-20 print:hidden">
<motion.div
initial="hidden"
whileInView="visible"
variants={FadeContainer}
viewport={{ once: true }}
className="max-w-4xl 2xl:max-w-5xl 3xl:max-w-7xl p-5 border-t-2 border-gray-200 dark:border-gray-400/10 mx-auto text-sm sm:text-base flex flex-col gap-5"
>
<div>
{currentSong?.isPlaying ? (
<WhenPlaying song={currentSong} />
) : (
<NotPlaying />
)}
<div></div>
</div>
<section className="grid grid-cols-3 gap-10">
<div className="flex flex-col gap-4 capitalize">
{navigationRoutes.slice(0, 4).map((text, index) => {
return (
<FooterLink key={index} id={index} route={text} text={text} />
);
})}
</div>
<div className="flex flex-col gap-4 capitalize">
{navigationRoutes
.slice(4, navigationRoutes.length)
.map((route, index) => {
let text = route;
if (route === "rss") text = "RSS";
return <FooterLink key={index} route={route} text={text} />;
})}
</div>
<div className="flex flex-col gap-4 capitalize">
{socialMedia.slice(0, 4).map((platform, index) => {
return (
<Link key={index} href={platform.url} passHref>
<motion.a
className="hover:text-black dark:hover:text-white w-fit"
variants={popUp}
target="_blank"
rel="noopener noreferrer"
href={platform.url}
>
{platform.title}
</motion.a>
</Link>
);
})}
</div>
</section>
</motion.div>
<div className="w-full flex justify-center">
<div
onClick={() => setShowQR(!showQR)}
className="bg-gray-700 text-white p-4 rounded-full cursor-pointer transition-all active:scale-90 hover:scale-105"
>
<HiOutlineQrcode className="w-6 h-6 " />
</div>
</div>
</footer>
);
}
function FooterLink({ route, text }) {
return (
<Link href={`/${route}`} passHref>
<motion.a
className="hover:text-black dark:hover:text-white w-fit"
variants={popUp}
href={`/${route}`}
>
{text}
</motion.a>
</Link>
);
}
function NotPlaying() {
return (
<div className="flex items-center gap-2 flex-row-reverse sm:flex-row justify-between sm:justify-start">
<SiSpotify className="w-6 h-6" />
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-3">
<div className="font-semibold md:text-lg text-black dark:text-white">
Not Playing
</div>
<span className="hidden md:inline-flex">—</span>
<p className="text-gray-500 text-xs sm:text-sm">Spotify</p>
</div>
</div>
);
}
function WhenPlaying({ song }) {
return (
<div className="flex flex-col gap-4">
<h4 className="text-lg font-semibold">Now Playing</h4>
<Link href={song.songUrl} passHref>
<a
href={song.songUrl}
className="flex items-center justify-between bg-gray-200 dark:bg-darkSecondary p-3 sm:p-4 rounded-sm"
>
<div className=" flex items-center gap-2">
<div className="w-10 h-10">
<Image
alt={song.title}
src={song.albumImageUrl}
width={40}
height={40}
layout="fixed"
quality={50}
placeholder="blur"
blurDataURL={song.albumImageUrl}
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-3">
<h3 className="font-semibold md:text-lg text-black dark:text-white animate-">
{song.title}
</h3>
<span className="hidden md:inline-flex">—</span>
<p className="text-gray-600 text-xs sm:text-sm">{song.artist}</p>
</div>
</div>
<div className="flex items-center gap-2">
<SiSpotify className="w-6 h-6 text-green-500 animate-[spin_2s_linear_infinite]" />
</div>
</a>
</Link>
</div>
);
}
Adding QRCodeContainer
Till now you might be wondering why there was showQR
in Footer.js
and the QR Code icon at the bottom. It was because that button will toggle QRCodeContainer
to show the QR Code of the current URL. So let's just create it.
Installing dependencies
We need to install react-qr-code and react-ripples to generate QR Code and show the Ripple effect respectively.
terminal
pnpm i react-qr-code react-ripples
Creating useWindowLocation Hook
We need to create a hook that will return the current window location.
Create hooks/useWindowLocation.js
hooks/useWindowLocation.js
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
export default function useWindowLocation() {
const [currentURL, setCurrentURL] = useState("");
const router = useRouter();
useEffect(() => {
setCurrentURL(window.location.href);
}, [router.asPath]);
return { currentURL };
}
After creating useWindowLocation
hook. Let's create QRCodeContainer
:
components/QRCodeContainer.js
/* Importing Modules */
import QRCode from "react-qr-code";
import Ripples from "react-ripples";
import useWindowLocation from "@hooks/useWindowLocation";
import { CgClose } from "react-icons/cg";
import { AnimatePresence, motion } from "framer-motion";
import { useDarkMode } from "@context/darkModeContext";
export default function QRCodeContainer({ showQR, setShowQR }) {
/* Get the Current URL from the hook that we have just created */
const { currentURL } = useWindowLocation();
const { isDarkMode } = useDarkMode();
/* As I have added download QR Code button then we need to create it as image
* I am just creating a 2D canvas and drawing then Converting that canvas to image/png and generating a download link
*/
function downloadQRCode() {
const svg = document.getElementById("QRCode");
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const pngFile = canvas.toDataURL("image/png");
const downloadLink = document.createElement("a");
downloadLink.download = "QRCode";
downloadLink.href = `${pngFile}`;
downloadLink.click();
};
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
}
return (
<>
<AnimatePresence>
{showQR && (
<motion.div
initial="hidden"
whileInView="visible"
exit="hidden"
variants={{
hidden: { y: "100vh", opacity: 0 },
visible: {
y: 0,
opacity: 1,
},
}}
transition={{
type: "spring",
bounce: 0.15,
}}
className="bg-white dark:bg-darkSecondary fixed inset-0 grid place-items-center"
style={{ zIndex: 10000 }}
>
<button
className="outline-none absolute right-5 top-5 text-black dark:text-white"
onClick={() => setShowQR(false)}
>
<CgClose className="w-8 h-8" />
</button>
<div className="text-black dark:text-white flex flex-col gap-2">
<h1 className="font-semibold text-xl">Share this page</h1>
<QRCode
id="QRCode"
value={currentURL}
bgColor={isDarkMode ? "#25282a" : "white"}
fgColor={isDarkMode ? "white" : "#25282a"}
/>
<Ripples
className="mt-2"
color={
isDarkMode ? "rgba(0,0,0, 0.2)" : "rgba(225, 225, 225, 0.2)"
}
>
<button
className="w-full px-3 py-2 font-medium bg-darkPrimary dark:bg-gray-100 text-white dark:text-darkPrimary rounded text-sm"
onClick={downloadQRCode}
>
Download
</button>
</Ripples>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
After implementing all the things I mentioned above You have created a footer and it will look something like this:
Our Layout is complete now. If you haven't add this layout to _app.js
then do that now:
pages/_app.js
/* previous code */
import Layout from "@layout/Layout";
function MyApp({ Component, pageProps }) {
return (
<DarkModeProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</DarkModeProvider>
);
}
export default MyApp;
Adding NProgressbar
NProgress is a tiny JavaScript plugin for creating a slim and nanoscopic progress bar that features realistic trickle animations to convince your users that something is happening. This will show the progress bar at the top of the webpage.
Installing nprogress
Run the following command to install the nprogress
terminal
pnpm i nprogress
Using nprogress
After installing nprogress
we need to use that in our pages/_app.js
:
pages/_app.js
/* ............Previous Code......... */
import { useEffect } from "react";
import { useRouter } from "next/router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
/* Progressbar Configurations */
NProgress.configure({
easing: "ease",
speed: 800,
showSpinner: false,
});
function MyApp({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
const start = () => {
NProgress.start();
};
const end = () => {
NProgress.done();
};
router.events.on("routeChangeStart", start);
router.events.on("routeChangeComplete", end);
router.events.on("routeChangeError", end);
return () => {
router.events.off("routeChangeStart", start);
router.events.off("routeChangeComplete", end);
router.events.off("routeChangeError", end);
};
}, [router.events]);
return (
<DarkModeProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</DarkModeProvider>
);
}
export default MyApp;
That's all we need to do to make that work. The above useEffect
will run as the route changes and according to that, it will handle the NProgress.
Adding Fonts
In the starting of the project we added some fonts to tailwind.config.js
tailwind.config.js
/* ...... */
fontFamily: {
inter: ["Inter", "sans-serif"],
sarina: ["Sarina", "cursive"],
barlow: ["Barlow", "sans-serif"],
mono: ["monospace"],
},
/* ...... */
Now we need to install them. There are many ways you can use these fonts such as-
- by using
@import
- by using
link
But we are not going to use them in this way. We are going to download these fonts locally and then we'll use them. This is because it will take time if your browser fetches the fonts every time users visit your website. It takes time and by using them locally , we can use Cache-Control
. Which will cache the fonts and load faster.
I am using Vercel to host my website and I guess you should too because it can provide
Cache-Control
which we will implement in just a moment.
Downloading Fonts
Good for you, you don't have to download each one of them individually. Follow the following steps:
- Download these fonts from Here as zip
- Extract the zip
- Create a
fonts
folder inside thepublic
folder if you don't have any - Put all the fonts inside that folder like this:
Files
my-project/
├── components
├── pages
└── public/
└── fonts/
├── Barlow/
│ ├── Barlow-400.woff2
│ ├── Barlow-500.woff2
│ ├── Barlow-600.woff2
│ ├── Barlow-700.woff2
│ └── Barlow-800.woff2
├── Sarina/
│ └── Sarina-400.woff2
└── Inter-var.woff2
Now you have downloaded and saved fonts in the right place. Let's add them now.
Link the fonts
Add fonts in _document.js
Now we need to link or add the fonts in pages/_document.js
. Create one if you don't have one:
pages/_document.js
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head>
{/* Barlow */}
<link
rel="preload"
href="/fonts/Barlow/Barlow-400.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/Barlow/Barlow-500.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/Barlow/Barlow-600.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/Barlow/Barlow-700.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/Barlow/Barlow-800.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* Inter */}
<link
rel="preload"
href="/fonts/Inter-var.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* Sarina */}
<link
rel="preload"
href="/fonts/Sarina/Sarina-400.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* Tailwind CSS Typography */}
<link
rel="stylesheet"
href="https://unpkg.com/@tailwindcss/typography@0.4.x/dist/typography.min.css"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
Add fonts in global.css
We need to use @font-face
for font optimization:
styles/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Barlow/Barlow-400.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Barlow/Barlow-500.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Barlow/Barlow-600.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Barlow/Barlow-700.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(/fonts/Barlow/Barlow-800.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/fonts/Inter-var.woff2) format("woff2");
}
@font-face {
font-family: "Sarina";
font-style: normal;
font-weight: normal;
font-display: swap;
src: url(/fonts/Sarina/Sarina-400.woff2) format("woff2");
}
Adding fonts to vercel.json
This step is important as we are using Vercel as a hosting platform and we need to use the Cache-Control
. So vercel.json
is like the following code:
vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"headers": [
{
"source": "/fonts/Barlow/Barlow-400.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Barlow/Barlow-500.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Barlow/Barlow-600.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Barlow/Barlow-700.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Barlow/Barlow-800.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Inter-var.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/Sarina/Sarina-400.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
}
That's all we need to do to add fonts in the project.
Adding Styling
I am using TailwindCSS to style the projects, but we need some custom styling which will take place in styles/global.css
. The following styles and classes will be used throughout the project. So just Copy Paste the following in your styles/global.css
(Don't add duplicates):
styles/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Barlow/Barlow-400.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Barlow/Barlow-500.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Barlow/Barlow-600.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Barlow/Barlow-700.woff2) format("woff2");
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(/fonts/Barlow/Barlow-800.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/fonts/Inter-var.woff2) format("woff2");
}
@font-face {
font-family: "Sarina";
font-style: normal;
font-weight: normal;
font-display: swap;
src: url(/fonts/Sarina/Sarina-400.woff2) format("woff2");
}
body,
html {
overflow-x: hidden;
scroll-behavior: auto;
}
body::-webkit-scrollbar {
width: 6px;
}
/* Adding Scroll Margin for top */
* {
scroll-margin-top: 80px;
}
@media screen and (max-width: 640px) {
* {
scroll-margin-top: 60px;
}
body::-webkit-scrollbar {
width: 2px;
}
}
pre::-webkit-scrollbar {
display: none;
}
body.dark {
background-color: #181a1b;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #b3b3b3;
}
.dark::-webkit-scrollbar-thumb {
background-color: #393e41;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.lock-scroll {
overflow: hidden !important;
}
/* For preventing the blue highlight color box on tap(click) */
* {
-webkit-tap-highlight-color: transparent;
}
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.auto-row {
-webkit-margin-before: auto;
margin-block-start: auto;
}
/* Code Line Highlighting START */
code {
counter-reset: line;
}
code span.line {
padding: 2px 12px;
border-left: 4px solid transparent;
}
code > .line::before {
counter-increment: line;
content: counter(line);
/* Other styling */
display: inline-block;
width: 1rem;
margin-right: 1rem;
text-align: right;
color: gray;
font-weight: 500;
border-right: 4px solid transparent;
}
.highlighted {
background: rgba(200, 200, 255, 0.1);
border-left: 4px solid #3777de !important;
filter: saturate(1.5);
}
/* Code Line Highlighting ENDS */
/* Nprogress bar Custom Styling (force) : STARTS */
#nprogress .bar {
background-color: rgba(0, 89, 255, 0.7) !important;
height: 3px !important;
}
.dark #nprogress .bar {
background: #fff !important;
}
#nprogress .peg {
box-shadow: none !important;
}
/* Nprogress bar Custom Styling (force) : ENDS */
.blogGrid {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
}
/* Layers Components or the custom class extends with tailwind */
@layer components {
.bottom_nav_icon {
@apply mb-[2px] text-2xl cursor-pointer;
}
.top-nav-link {
@apply list-none mx-1 px-3 py-1 border-black dark:border-white transition-all duration-200 hover:rounded-md hover:bg-gray-100 dark:hover:bg-darkSecondary cursor-pointer text-lg font-semibold select-none sm:text-sm md:text-base;
}
.contact_field {
@apply text-sm font-medium text-black dark:text-white w-full px-4 py-2 m-2 rounded-md border-none outline-none shadow-inner shadow-slate-200 dark:shadow-zinc-800 focus:ring-1 focus:ring-purple-500 dark:bg-darkPrimary dark:placeholder-gray-500;
}
.title_of_page {
@apply text-center text-xl font-bold dark:bg-darkPrimary dark:text-gray-100;
}
.icon {
@apply text-2xl sm:text-3xl m-1 transform duration-200 lg:hover:scale-150 text-zinc-500 hover:text-zinc-800 dark:hover:text-white cursor-pointer;
}
.page_container {
@apply p-5 md:px-24 pb-10 dark:bg-darkPrimary dark:text-gray-200 grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 3xl:grid-cols-5;
}
.blog_bottom_icon {
@apply text-3xl p-1 bg-gray-100 dark:bg-darkSecondary sm:bg-transparent ring-1 dark:ring-gray-500 ring-gray-300 sm:hover:bg-gray-100 rounded-md cursor-pointer ml-1;
}
.blog_bottom_button {
@apply block sm:hidden py-1 w-full lg:hover:bg-gray-300 cursor-pointer bg-gray-200 rounded-md transform duration-100 active:scale-90 select-none;
}
.user_reaction {
@apply flex font-semibold items-center cursor-pointer w-full justify-center sm:justify-start sm:w-auto space-x-1 text-base;
}
.project_link {
@apply text-center bg-gray-200 p-2 my-1 rounded-full dark:bg-darkSecondary dark:text-white cursor-pointer shadow dark:shadow-gray-500;
}
.clickable_button {
@apply transform duration-100 active:scale-90 lg:hover:scale-105;
}
.home-section-container {
@apply flex gap-2 overflow-x-scroll p-5 md:px-24 w-full min-h-[200px] select-none snap-x lg:snap-none;
}
.home-content-section {
@apply relative min-w-[250px] xl:min-w-[300px] break-words shadow shadow-black/20 dark:shadow-white/20 dark:bg-darkSecondary ring-gray-400 rounded-xl p-3 cursor-pointer select-none lg:hover:scale-105 scale-95 transition bg-white snap-center lg:snap-align-none md:first:ml-24 md:last:mr-24;
}
.blog-hover-button {
@apply flex items-center space-x-2 border-2 border-white dark:border-zinc-600 px-3 py-1 font-semibold w-min text-white dark:text-white hover:bg-white dark:hover:bg-zinc-600 hover:text-black;
}
.hover-slide-animation {
@apply relative overflow-hidden before:absolute before:h-full before:w-40 before:bg-stone-900 dark:before:bg-gray-50 before:opacity-10 dark:before:opacity-5 before:-right-10 before:-z-10 before:rotate-[20deg] before:scale-y-150 before:top-4 hover:before:scale-[7] before:duration-700;
}
.pageTop {
@apply mt-[44px] md:mt-[60px] max-w-4xl 2xl:max-w-5xl 3xl:max-w-7xl relative mx-auto p-4 mb-10 text-neutral-900 dark:text-neutral-200;
}
.utilities-svg {
@apply !pointer-events-none mb-4 w-8 h-8;
}
.card {
@apply bg-white dark:bg-darkSecondary p-5 sm:p-10 flex flex-col sm:flex-row gap-8 items-center max-w-2xl shadow-md rounded-lg mt-[30%] sm:mt-8 transition-all;
}
.blog-container {
@apply !w-full dark:text-neutral-400 my-5 font-medium;
}
}
@layer base {
body {
@apply font-inter bg-darkWhite;
}
button {
@apply outline-none;
}
hr {
@apply !mx-auto !w-1/2 h-0.5 !bg-gray-700 dark:!bg-gray-300 border-0 !rounded-full;
}
table {
@apply !border-collapse text-left;
}
table thead tr > th,
table tbody tr > td {
@apply !p-2 border border-gray-400 align-middle;
}
table thead tr > th {
@apply text-black dark:text-white;
}
table thead tr {
@apply align-text-top;
}
table th {
@apply font-bold;
}
table a {
@apply !text-blue-500 dark:!text-blue-400;
}
strong {
@apply !text-black dark:!text-white !font-bold;
}
/* For Blog page to remove the underline */
h2 > a,
h3 > a,
h4 > a,
h5 > a,
h6 > a {
@apply !text-black dark:!text-white !font-bold !no-underline;
}
}
@layer utilities {
/* Hiding the arrows in the input number */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
Adding SEO Support
We need to use metadata to improve SEO. So Before Creating pages and portfolio, let's add support for SEO.
Create MetaData
We need metadata to add that to the website to improve SEO.So I have created a static Data file that contains the title, description, image (when the page is shared), and keywords. Copy and paste the following code inside content/meta.js
and update this with your information.
content/meta.js
export default {
home: {
title: "",
description:
"Hey, I am Jatin Sharma. A Front-end Developer/React Developer from India who loves to design and code. I use React.js or Next.js to build the web application interfaces and the functionalities. At the moment, I am pursuing my Bachelor's degree in Computer Science.",
image: "https://imgur.com/KeJgIVl.png",
keywords: "portfolio jatin, portfolio j471n, jatin blogs",
},
stats: {
title: "Statistics -",
description:
"These are my personal statistics about me. It includes My Blogs and github Stats and top music stats.",
image: "https://imgur.com/9scFfW5.png",
keywords: "stats, Statistics",
},
utilities: {
title: "Utilities - ",
description:
"In case you are wondering What tech I use, Here's the list of what tech I'm currently using for coding on the daily basis. This list is always changing.",
image: "https://imgur.com/MpfymCd.png",
keywords: "Utilities, what i use?, utils, setup, uses,",
},
blogs: {
title: "Blogs -",
description:
"I've been writing online since 2021, mostly about web development and tech careers. In total, I've written more than 50 articles till now.",
image: "https://imgur.com/nbNLLZk.png",
keywords: "j471n blog, blog, webdev, react",
},
bookmark: {
title: "Bookmarks -",
description: "Bookmarked Blogs of Jatin Sharma's blogs by you",
image: "https://imgur.com/5XkrVPq.png",
keywords: "bookmark, blogs, ",
},
certificates: {
title: "Certificates -",
description:
"I've participated in many contests, courses and test and get certified in many skills. You can find the certificates below.",
image: "https://imgur.com/J0q1OdT.png",
keywords: "Certificates, verified",
},
projects: {
title: "Projects -",
description:
"I've been making various types of projects some of them were basics and some of them were complicated.",
image: "https://imgur.com/XJqiuNK.png",
keywords: "projects, work, side project,",
},
about: {
title: "About -",
description:
"Hey, I am Jatin Sharma. A Front-end Developer/React Developer from India who loves to design and code. I use React.js or Next.js to build the web application interfaces and the functionalities. At the moment, I am pursuing my Bachelor's degree in Computer Science.",
image: "https://imgur.com/b0HRaPv.png",
keywords: "about",
},
};
Creating Meta Data Component
We need a Component that we can use on every page that uses the above metadata and add that to the head
of the page. There is one more reason we need that we'll create this app as PWA. Copy & Paste the above code inside components/MetaData.js
components/MetaData.js
import Head from "next/head";
import useWindowLocation from "@hooks/useWindowLocation";
export default function MetaData({
title,
description,
previewImage,
keywords,
}) {
const { currentURL } = useWindowLocation();
return (
<Head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content={description || "Jatin Sharma"} />
<title>{`${title || ""} Jatin Sharma`}</title>
<meta name="theme-color" content="#000" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png"></link>
<meta httpEquiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="author" content="Jatin Sharma"></meta>
<meta name="robots" content="index,follow" />
<meta
name="keywords"
content={`${keywords || ""} Jatin, Jatin sharma, j471n, j471n_`}
/>
{/* Og */}
<meta property="og:title" content={`${title || ""} Jatin Sharma`} />
<meta property="og:description" content={description || "Jatin Sharma"} />
<meta property="og:site_name" content="Jatin Sharma" />
<meta property="og:url" content={currentURL} key="ogurl" />
<meta property="og:image" content={previewImage || ""} />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content="@j471n_" />
<meta name="twitter:title" content={`${title || ""} Jatin Sharma`} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={previewImage || ""} />
<meta name="twitter:image:alt" content={title || "Jatin Sharma"}></meta>
<meta name="twitter:domain" content={currentURL} />
</Head>
);
}
Homepage
We have added almost everything we need to start the project. Let's Create our Homepage First. It will have four sections:
- User Profile
- Skills
- Recent Posts
- Contact (Get in Touch)
We will create them one by one.
User Profile
This is the first section of the homepage. which should look like this:
pages/index.js
import Image from "next/image";
import {
FadeContainer,
headingFromLeft,
opacityVariant,
popUp,
} from "@content/FramerMotionVariants";
import { homeProfileImage } from "@utils/utils"; // not created yet
import { motion } from "framer-motion";
import { FiDownload } from "react-icons/fi";
import Ripples from "react-ripples";
import Metadata from "@components/MetaData";
import pageMeta from "@content/meta";
export default function Home() {
return (
<>
<Metadata
description={pageMeta.home.description}
previewImage={pageMeta.home.image}
keywords={pageMeta.home.keywords}
/>
</>
);
}
The above code just adds the meta-data
to the page. One thing we haven't created yet is homeProfileImage
. Let's create it quick:
Inside utils/utils.js
add your profile image URL (It should be in png or jpg format)
utils/utils.js
export const homeProfileImage = "https://imgur.com/mKrXwWF.png";
Now we are good. We just need to add JSX to the index.js
let's add that too.
pages/index.js
/* ...........Previous code.......... */
export default function Home({ blogs, skills }) {
return (
<>
<Metadata
description={pageMeta.home.description}
previewImage={pageMeta.home.image}
keywords={pageMeta.home.keywords}
/>
{/* Following is the new Code */}
<div className="relative dark:bg-darkPrimary dark:text-gray-100 max-w-4xl 2xl:max-w-5xl 3xl:max-w-7xl mx-auto">
<motion.section
initial="hidden"
whileInView="visible"
variants={FadeContainer}
viewport={{ once: true }}
className="grid place-content-center py-20 min-h-screen"
>
<div className="w-full relative mx-auto flex flex-col items-center gap-10">
<motion.div
variants={popUp}
className="relative w-44 h-44 xs:w-52 xs:h-52 flex justify-center items-center rounded-full p-3 before:absolute before:inset-0 before:border-t-4 before:border-b-4 before:border-black before:dark:border-white before:rounded-full before:animate-photo-spin"
>
<Image
src={homeProfileImage}
className="rounded-full shadow filter saturate-0"
width={400}
height={400}
alt="cover Profile Image"
quality={75}
priority={true}
/>
</motion.div>
<div className="w-full flex flex-col p-5 gap-3 select-none text-center ">
<div className="flex flex-col gap-1">
<motion.h1
variants={opacityVariant}
className="text-5xl lg:text-6xl font-bold font-sarina"
>
Jatin Sharma
</motion.h1>
<motion.p
variants={opacityVariant}
className="font-medium text-xs md:text-sm lg:text-lg text-gray-500"
>
React Developer, Competitive Programmer
</motion.p>
</div>
<motion.p
variants={opacityVariant}
className=" text-slate-500 dark:text-gray-300 font-medium text-sm md:text-base text-center"
>
I am currently perusing my Bachelor Degree in Computer Science.
I can code in Python, C, C++, etc.
</motion.p>
</div>
<motion.div className="rounded-md overflow-hidden" variants={popUp}>
<Ripples className="w-full" color="rgba(0, 0, 0, 0.5)">
<button
className="flex items-center gap-2 px-5 py-2 border rounded-md border-gray-500 dark:border-gray-400 select-none hover:bg-gray-100 dark:hover:bg-neutral-800 outline-none"
onClick={() => window.open("/resume")}
>
<FiDownload />
<p>Resume</p>
</button>
</Ripples>
</motion.div>
</div>
</motion.section>
</div>
</>
);
}
In the above code, when the resume button is Clicked, we are directing the user to /resume
route which we have not created yet. We won't create it because we are going to use vercel.json
to redirect the user. Let's see how we do that:
vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"headers": [
/* ......Fonts...... */
],
"redirects": [
{
"source": "/home",
"destination": "/"
},
{
"source": "/resume",
"destination": "/resume.pdf"
},
]
}
The above code shows that we redirect the user from /home
to /
and /resume
to /resume.pdf
. You just need to put your resume as a pdf in the public
directory. which will later look like this:
Files
my-project/
├── components
├── pages
└── public/
├── fonts/
└── resume.pdf
Skills
This is the second section of the homepage. which should look like this:
For Skills, I have also used static data. which you can manipulate.
Create content/skillsData.js
content/skillsData.js
module.exports = [
{
name: "HTML",
level: 100,
pinned: false,
},
{
name: "CSS",
level: 95,
pinned: true,
},
{
name: "Javascript",
level: 80,
pinned: true,
},
{
name: "SASS",
level: 80,
pinned: false,
},
{
name: "React.js",
level: 80,
pinned: true,
},
{
name: "Next.js",
level: 80,
pinned: true,
},
{
name: "Tailwind CSS",
level: 100,
pinned: true,
},
/* .....Add more..... */
];
The above code returns the array of objects and each object contains three keys:
- name: name of the skill
- level: how well you know the skill (optional)
- pinned: (true/false) skill will show on the home screen if it is true
We will need to create two things:
- getPinnedSkills: function that returns only pinned skills
- SkillSection: Component for skills
Creating getPinnedSkills
Create lib/dataFetch.js
and inside that add the following code:
lib/dataFetch.js
import skills from "@content/skillsData";
export function getPinnedSkills() {
return skills.filter((skill) => skill.pinned);
}
That's it, now we need to implement them in pages/index.js
:
pages/index.js
/* ...............Previous Code........... */
import { getPinnedSkills } from "@lib/dataFetch";
/* HomePage Component */
- export default function Home() {
+ export default function Home({ skills ) {
/* .....New Code........ */
export async function getStaticProps() {
const skills = getPinnedSkills();
return {
props: { skills },
};
}
Add getStaticProps
at the bottom of the index.js
and call getPinnedSkills
it returns skills
as props which we can use inside the Home
component.
Here
+
shows what is added and-
shows what is deleted.
Creating SkillSection
Component
Now we are creating a separate component for Skills.
components/Home/SkillSection.js
import { FadeContainer, popUp } from "@content/FramerMotionVariants";
import { HomeHeading } from "../../pages"; // ----> not created yet
import { motion } from "framer-motion";
import {
SiHtml5,
SiCss3,
SiJavascript,
SiNextdotjs,
SiTailwindcss,
SiPython,
SiGit,
SiMysql,
SiFirebase,
} from "react-icons/si";
import { FaReact } from "react-icons/fa";
import { useDarkMode } from "@context/darkModeContext";
import * as WindowsAnimation from "@lib/windowsAnimation"; //-----> not created yet
export default function SkillSection({ skills }) {
const { isDarkMode } = useDarkMode();
return (
<section className="mx-5">
<HomeHeading title="My Top Skills" />
<motion.div
initial="hidden"
whileInView="visible"
variants={FadeContainer}
viewport={{ once: true }}
className="grid my-10 gap-4 grid-cols-3"
>
{skills.map((skill, index) => {
const Icon = chooseIcon(skill.name.toLowerCase());
return (
<motion.div
variants={popUp}
key={index}
title={skill.name}
onMouseMove={(e) =>
WindowsAnimation.showHoverAnimation(e, isDarkMode)
}
onMouseLeave={(e) => WindowsAnimation.removeHoverAnimation(e)}
className="p-4 flex items-center justify-center sm:justify-start gap-4 bg-gray-50 hover:bg-white dark:bg-darkPrimary hover:dark:bg-darkSecondary border rounded-sm border-gray-300 dark:border-neutral-700 transform origin-center md:origin-top group"
>
<div className="relative transition group-hover:scale-110 sm:group-hover:scale-100 select-none pointer-events-none">
<Icon className="w-8 h-8" />
</div>
<p className="hidden sm:inline-flex text-sm md:text-base font-semibold select-none pointer-events-none">
{skill.name}
</p>
</motion.div>
);
})}
</motion.div>
</section>
);
}
/* To choose the Icon according to the Name */
function chooseIcon(title) {
let Icon;
switch (title) {
case "python":
Icon = SiPython;
break;
case "javascript":
Icon = SiJavascript;
break;
case "html":
Icon = SiHtml5;
break;
case "css":
Icon = SiCss3;
break;
case "next.js":
Icon = SiNextdotjs;
break;
case "react.js":
Icon = FaReact;
break;
case "tailwind css":
Icon = SiTailwindcss;
break;
case "firebase":
Icon = SiFirebase;
break;
case "git":
Icon = SiGit;
break;
case "git":
Icon = SiGit;
break;
case "mysql":
Icon = SiMysql;
break;
default:
break;
}
return Icon;
}
In the above code, we are creating a grid
that shows the skills with their icon. as we did not add icons to the content/skillData.js
. So here we are choosing the Icon (I know that's not the best way). We add the windows hover animation onMouseMove
and remove it onMouseLeave
. We will create that in a moment. We have not created two things in the above code:
HomeHeading
: Animated Heading for the ProjectWindowsAnimation
: Windows Hover animation for Skill Card
Creating HomeHeading
Component
You can create HomeHeading
Component in your components folder, but I create inside pages/index.js
at the bottom:
pages/index.js
/* ......Home page Component....... */
export function HomeHeading({ title }) {
return (
<AnimatedHeading
className="w-full font-bold text-3xl text-left my-2 font-inter"
variants={headingFromLeft}
>
{title}
</AnimatedHeading>
);
}
/* ......getStaticProps()....... */
In the above code, We haven't created AnimatedHeading
yet. You can find that here
Creating WindowsAnimation
We will create a window's hover animation for the Skill card. For that Create a file lib/WindowsAnimation.js
:
lib/WindowsAnimation.js
export function showHoverAnimation(e, isDarkMode) {
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left; // x position within the element.
const y = e.clientY - rect.top; // y position within the element.
if (isDarkMode) {
e.target.style.background = `radial-gradient(circle at ${x}px ${y}px , rgba(255,255,255,0.2),rgba(255,255,255,0) )`;
e.target.style.borderImage = `radial-gradient(20% 75% at ${x}px ${y}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.1) ) 1 / 1px / 0px stretch `;
}
}
export function removeHoverAnimation(e) {
e.target.style.background = null;
e.target.style.borderImage = null;
}
It has two functions showHoverAnimation
to show the animation and removeHoverAnimation
to remove the animation the cursor is removed. After implementing that it looks something like this (It's for dark mode only):
Now we just need to import SkillSection
into pages/index.js
:
pages/index.js
/* ...Other imports.... */
import BlogsSection from "@components/Home/BlogsSection";
export default function Home({ blogs, skills }) {
/*....other code */
return (
<>
{/* previous JSX */}
<SkillSection skills={skills} />
</>
);
}
/* ........other code....... */
Recent Posts
To implement this functionality we need to create a Blog. For this, I am using MDX. We are going to cover this later in this article when we will create a Blog page. or you can directly jump on Blog section.
Contact
Now We need to Create a Contact Form through which any person can contact me via Email. We are going to use Email.js to send the mail You can see their docs to set up (it's very simple to setup). If you want you can use something else its totally up to you. Our contact form looks like this:
Installing dependencies
You need to install two dependencies before we start building the form:
- react-toastify: allows you to add notifications to your app
- @emailjs/browser: To send the emails
terminal
pnpm i react-toastify @emailjs/browser
Creating Contact Component
First Create a file name index.js
inside components/Contact
folder:
components/Contact/index.js
export { default } from "./Contact";
In the above code, we are importing Contact
Component. let's create it:
components/Contact.js
/* importing modules */
import React from "react";
import { popUpFromBottomForText } from "../../content/FramerMotionVariants";
import AnimatedHeading from "../FramerMotion/AnimatedHeading";
import "react-toastify/dist/ReactToastify.css";
import ContactForm from "./ContactForm"; // ======>> not created yet
import AnimatedText from "../FramerMotion/AnimatedText"; // ======>> not created yet
export default function Contact() {
return (
<div id="contact" className="dark:bg-darkPrimary !relative">
{/* Get in touch top section */}
<section className="w-full-width text-center pt-6 dark:bg-darkPrimary dark:text-white">
<AnimatedHeading
variants={popUpFromBottomForText}
className="font-bold text-4xl"
>
Get in touch
</AnimatedHeading>
<AnimatedText
variants={popUpFromBottomForText}
className="px-4 py-2 font-medium text-slate-400"
>
Have a little something, something you wanna talk about? Please feel
free to get in touch anytime, whether for work or to just Hi 🙋♂️.
</AnimatedText>
</section>
{/* Wrapper Container */}
<section className="flex flex-col lg:flex-row w-full mx-auto px-5 dark:bg-darkPrimary dark:text-white lg:pb-10">
{/* Left Contact form section */}
<div className="w-full mx-auto mt-10">
<AnimatedHeading
variants={popUpFromBottomForText}
className="text-2xl font-bold w-full text-center my-2"
>
Connect with me
</AnimatedHeading>
<ContactForm />
</div>
</section>
</div>
);
}
The above code is simple. We just didn't create two things:
AnimatedText
: Animation paragraphContactForm
: form component
You can find AnimatedText
component Here
Creating ContactForm Component
components/Contact/ContactForm.js
import { useState } from "react";
import { AiOutlineLoading } from "react-icons/ai";
import { ToastContainer, toast } from "react-toastify";
import { useDarkMode } from "../../context/darkModeContext";
import emailjs from "@emailjs/browser";
import { motion } from "framer-motion";
import {
FadeContainer,
mobileNavItemSideways,
} from "../../content/FramerMotionVariants";
import Ripples from "react-ripples";
// initial State of the form
const initialFormState = {
to_name: "Jatin Sharma",
first_name: "",
last_name: "",
email: "",
subject: "",
message: "",
};
export default function Form() {
const [emailInfo, setEmailInfo] = useState(initialFormState);
const [loading, setLoading] = useState(false);
const { isDarkMode } = useDarkMode();
/* Here we send an Email using emailjs you can get service_id, template_id, and user_id after sign up on their site */
function sendEmail(e) {
e.preventDefault();
setLoading(true);
emailjs
.send(
process.env.NEXT_PUBLIC_YOUR_SERVICE_ID,
process.env.NEXT_PUBLIC_YOUR_TEMPLATE_ID,
emailInfo,
process.env.NEXT_PUBLIC_YOUR_USER_ID
)
.then((res) => {
setLoading(false);
setEmailInfo(initialFormState);
toast.success("Message Sent ✌");
})
.catch((err) => {
console.log(err.text);
setLoading(false);
toast.error("😢 " + err.text);
});
}
/* For Form Validation I simply check each field should not be empty */
function validateForm() {
for (const key in emailInfo) {
if (emailInfo[key] === "") return false;
}
return true;
}
/* When user is typing and Press Ctrl+Enter then it will try to send the mail after validating */
function submitFormOnEnter(event) {
if ((event.keyCode == 10 || event.keyCode == 13) && event.ctrlKey) {
if (validateForm()) {
return sendEmail(event);
}
toast.error("Looks like you have not filled the form");
}
}
return (
<>
<motion.form
initial="hidden"
whileInView="visible"
variants={FadeContainer}
viewport={{ once: true }}
className="w-full flex flex-col items-center max-w-xl mx-auto my-10 dark:text-gray-300"
onSubmit={sendEmail}
onKeyDown={submitFormOnEnter}
>
{/* First Name And Last Name */}
<div className="w-full grid grid-cols-2 gap-6">
<motion.div
variants={mobileNavItemSideways}
className="relative z-0 w-full mb-6 group"
>
<input
type="text"
name="first_name"
id="floating_first_name"
className="block py-2 mt-2 px-0 w-full text-sm text-white-900 bg-transparent border-0 border-b-2 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-white focus:outline-none focus:ring-0 focus:border-black peer"
placeholder=" "
required
value={emailInfo.first_name}
onChange={(e) =>
setEmailInfo({
...emailInfo,
[e.target.name]: e.target.value,
})
}
/>
<label
htmlFor="floating_first_name"
className="peer-focus:font-medium absolute text-sm text-slate-400 dark:text-gray-400 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:left-0 peer-focus:text-black dark:peer-focus:text-white peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>
First name
</label>
</motion.div>
<motion.div
variants={mobileNavItemSideways}
className="relative z-0 w-full mb-6 group"
>
<input
type="text"
name="last_name"
id="floating_last_name"
className="block py-2 mt-2 px-0 w-full text-sm text-gray-900 bg-transparent border-0 border-b-2 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-white focus:outline-none focus:ring-0 focus:border-black peer"
placeholder=" "
required
value={emailInfo.last_name}
onChange={(e) =>
setEmailInfo({
...emailInfo,
[e.target.name]: e.target.value,
})
}
/>
<label
htmlFor="floating_last_name"
className="peer-focus:font-medium absolute text-sm text-slate-400 dark:text-gray-400 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:left-0 peer-focus:text-black dark:peer-focus:text-white peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>
Last name
</label>
</motion.div>
</div>
<motion.div
variants={mobileNavItemSideways}
className="relative z-0 w-full mb-6 group"
>
<input
type="email"
name="email"
id="floating_email"
className="block py-2 mt-2 px-0 w-full text-sm text-gray-900 bg-transparent border-0 border-b-2 border-gray-300 appearance-none dark:text-white dark:border-gray-600 focus:outline-none focus:ring-0 focus:dark:border-white focus:border-black peer"
placeholder=" "
required
value={emailInfo.email}
onChange={(e) =>
setEmailInfo({
...emailInfo,
[e.target.name]: e.target.value,
})
}
/>
<label
htmlFor="floating_email"
className="peer-focus:font-medium absolute text-sm text-slate-400 dark:text-gray-400 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:left-0 peer-focus:text-black dark:peer-focus:text-white peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>
Email address
</label>
</motion.div>
<motion.div
variants={mobileNavItemSideways}
className="relative z-0 w-full mb-6 group"
>
<input
type="subject"
name="subject"
id="floating_subject"
className="block py-2 mt-2 px-0 w-full text-sm text-gray-900 bg-transparent border-0 border-b-2 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-white focus:outline-none focus:ring-0 focus:border-black peer"
placeholder=" "
required
value={emailInfo.subject}
onChange={(e) =>
setEmailInfo({
...emailInfo,
[e.target.name]: e.target.value,
})
}
/>
<label
htmlFor="floating_subject"
className="peer-focus:font-medium absolute text-sm text-slate-400 dark:text-gray-400 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:left-0 peer-focus:text-black dark:peer-focus:text-white peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>
Subject
</label>
</motion.div>
<motion.div
variants={mobileNavItemSideways}
className="relative z-0 w-full mb-6 group"
>
<textarea
name="message"
id="floating_message"
className="block py-2 mt-2 px-0 w-full text-sm text-gray-900 bg-transparent border-0 border-b-2 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-white focus:outline-none focus:ring-0 peer min-h-[100px] resize-y focus:border-black"
placeholder=" "
required
value={emailInfo.message}
onChange={(e) =>
setEmailInfo({
...emailInfo,
[e.target.name]: e.target.value,
})
}
/>
<label
htmlFor="floating_message"
className="peer-focus:font-medium absolute text-sm text-slate-400 dark:text-gray-400 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:left-0 peer-focus:text-black dark:peer-focus:text-white peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>
Message
</label>
</motion.div>
<motion.div
variants={mobileNavItemSideways}
className="w-full sm:max-w-sm rounded-lg overflow-hidden "
>
<Ripples
className="flex w-full justify-center"
color="rgba(225, 225,225,0.2)"
>
<button
type="submit"
className="text-white bg-neutral-800 dark:bg-darkSecondary font-medium rounded-lg text-sm w-full px-4 py-3 text-center relative overflow-hidden transition duration-300 outline-none active:scale-95"
>
<div className="relative w-full flex items-center justify-center">
<p
className={
loading ? "inline-flex animate-spin mr-3" : "hidden"
}
>
<AiOutlineLoading className="font-bold text-xl" />
</p>
<p>{loading ? "Sending..." : "Send"}</p>
</div>
</button>
</Ripples>
</motion.div>
</motion.form>
<ToastContainer
theme={isDarkMode ? "dark" : "light"}
style={{ zIndex: 1000 }}
/>
</>
);
}
In the above code, I am just rendering the Contact Form and sending the email via emailjs. Every field is required however I am submitting a form when the user press CTRL + Enter
as it doesn't care about the required field that's why I have to manually check that every field should be filled using validateForm
function. It will give you a toast about if the mail is sent as shown below:
This is our homepage that we have just created. It doesn't have Recent Blogs section yet. we will create that when we create Blog Page if you are curious then you jump to Blog section to learn how we are going to implement that.
Stats Page
The stats page contains my personal statistics about my dev.to blog, GitHub and Spotify. Following is the image how it looks like:
The above image shows that the Stats page has three sections:
- Blog Stats
- Top streams
- Top Artists
we will create those one by one. But first, create a file called stats.js
inside the pages
folder:
pages/stats.js
import React from "react";
export default function Stats() {
return <></>;
}
Blog and GitHub Stats
In this section, we'll be creating Blog and GitHub statistics cards. which will look like the following:
Let's add the Header first. we are going to create a component called PageTop
which will be used throughout the project.
Create components/PageTop.js
:
components/PageTop
import {
fromLeftVariant,
opacityVariant,
} from "../content/FramerMotionVariants"; // ===> not created yet
import AnimatedHeading from "./FramerMotion/AnimatedHeading";
import AnimatedText from "./FramerMotion/AnimatedText";
export default function PageTop({ pageTitle, headingClass, children }) {
return (
<div className="w-full flex flex-col gap-3 py-5 select-none mb-10">
<AnimatedHeading
variants={fromLeftVariant}
className={`text-4xl md:text-5xl font-bold text-neutral-900 dark:text-neutral-200 ${headingClass}`}
>
{pageTitle}
</AnimatedHeading>
<AnimatedText
variants={opacityVariant}
className="font-medium text-lg text-gray-400"
>
{children}
</AnimatedText>
</div>
);
}
In this, we are going to take the pageTitle
as a prop and the children
as the description of the project. We haven't created fromLeftVariant
and opacityVariant
. Let's do it then:
content/FramerMotionVariants.js
export const opacityVariant = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { delay: 0.2 } },
};
export const fromLeftVariant = {
hidden: { x: -100, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: {
duration: 0.1,
type: "spring",
stiffness: 100,
},
},
};
Now add this PageTop
component to the pages/stats.js
:
pages/stats.js
import React from "react";
import useSWR from "swr";
import { motion } from "framer-motion";
import {
FadeContainer,
fromLeftVariant,
popUpFromBottomForText,
} from "@content/FramerMotionVariants";
import fetcher from "@lib/fetcher";
import MetaData from "@components/MetaData";
import PageTop from "@components/PageTop";
import AnimatedHeading from "@components/FramerMotion/AnimatedHeading";
import AnimatedText from "@components/FramerMotion/AnimatedText";
import pageMeta from "@content/meta";
export default function Stats() {
return (
<>
<MetaData
title={pageMeta.stats.title}
description={pageMeta.stats.description}
previewImage={pageMeta.stats.image}
keywords={pageMeta.stats.keywords}
/>
<section className="pageTop font-inter">
<PageTop pageTitle="Statistics">
These are my personal statistics about my Dev.to Blogs, Github and Top
Streamed Music on Spotify.
</PageTop>
</section>
</>
);
}
In the above code, I have imported the modules and rendered the MetaData
and PageTop
.
Dev.to Stats
Now that we have added the top section of the page, let's implement the Dev.to stats. For this, I am going to use Dev.to API to get information about my blog stats. But this API has some limitations such as that it can only show a maximum of 1000 objects on a single page (not all of them). So we will be tackling that.
I have also written a blog on Dev.to API if you are interested then go to the following link:
How to use the dev.to API?
I am going to use Next.js API routes as well to fetch the data. So let's create pages/api/stats/devto.js
file. This is going to be the API route which will return the stats.
pages/api/stats/devto.js
/* File: pages/api/stats/devto.js */
import { allFollowers, allPosts } from "@lib/devto"; // ====> not created yet
export default async function handler(req, res) {
const followers = await allFollowers();
const posts = await allPosts();
let totalViews = 0;
let totalLikes = 0;
let totalComments = 0;
posts.forEach((post) => {
totalLikes += post.public_reactions_count;
totalViews += post.page_views_count;
totalComments += post.comments_count;
});
res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);
return res.status(200).json({
followers: followers,
likes: totalLikes,
views: totalViews,
comments: totalComments,
posts: posts.length,
});
}
We are using two functions allFollowers
and allPosts
that will return all the followers I have and all the blogs/posts I have published. Then we are iterating over each post and extracting the likes (public reactions), views, and comments. After that, I am setting Cache so that browser doesn't fetch the data again and again. we are revalidating the request every 43200 seconds which is 12 hours.
Let's create allFollowers
and allFollowers
functions inside lib/devto.js
:
lib/devto.js
const PER_PAGE = 1000;
const DEV_API = process.env.NEXT_PUBLIC_BLOGS_API;
const getPageOfFollowers = async (page) => {
const perPageFollowers = await fetch(
`https://dev.to/api/followers/users?per_page=${PER_PAGE}&page=${page}`,
{
headers: {
api_key: DEV_API,
},
}
)
.then((response) => response.json())
.catch((err) => console.log(err));
return perPageFollowers.length;
};
export const allFollowers = async () => {
let numReturned = PER_PAGE;
let page = 1;
var totalFollowers = 0;
while (numReturned === PER_PAGE) {
const followers = await getPageOfFollowers(page);
totalFollowers += followers;
numReturned = followers;
page++;
}
return totalFollowers;
};
const getPageOfPosts = async (page) => {
const perPagePosts = await fetch(
`https://dev.to/api/articles/me?per_page=${PER_PAGE}&page=${page}`,
{
headers: {
api_key: DEV_API,
},
}
)
.then((response) => response.json())
.catch((err) => console.log(err));
return perPagePosts;
};
export const allPosts = async () => {
let numReturned = PER_PAGE;
let page = 1;
var totalPosts = [];
while (numReturned === PER_PAGE) {
const posts = await getPageOfPosts(page);
totalPosts.push(...posts);
numReturned = posts.length;
page++;
}
return totalPosts;
};
You need to add NEXT_PUBLIC_BLOGS_API
to your .env.local
and then restart your server. If you don't know how to get the Dev.to API, then click here and go to the bottom of the page and generate the API.
The above code has two extra functions getPageOfFollowers
and getPageOfPosts
these just help us to get the stats of the one page and request the API until all the data is received.
Now let's fetch it in our pages/stats.js
:
pages/stats.js
/* File: pages/stats.js */
/* ..............Other modules........... */
import StatsCard from "@components/Stats/StatsCard"; // ====> not created yet
export default function Stats() {
const { data: devto } = useSWR("/api/stats/devto", fetcher);
const stats = [
{
title: "Total Posts",
value: devto?.posts.toLocaleString(),
},
{
title: "Blog Followers",
value: devto?.followers.toLocaleString(),
},
{
title: "Blog Reactions",
value: devto?.likes.toLocaleString(),
},
{
title: "Blog Views",
value: devto?.views.toLocaleString(),
},
{
title: "Blog Comments",
value: devto?.comments.toLocaleString(),
},
];
return (
<>
{/* .......old code...... (<PageTop />) */}
{/* Enter the following code under the `PageTop` component.*/}
{/* Blogs and github stats */}
<motion.div
className="grid xs:grid-cols-2 sm:!grid-cols-3 md:!grid-cols-4 gap-5 my-10"
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{stats.map((stat, index) => (
<StatsCard
key={index}
title={stat.title}
value={
stat.value === undefined ? (
<div className="w-28 h-8 rounded-sm bg-gray-300 dark:bg-neutral-700 animate-pulse" />
) : (
stat.value
)
}
/>
))}
</motion.div>
{/* ........other code...... */}
</>
);
}
I have created an array to iterate over and then render StatsCard
which we will create in a moment. In StatsCard
while passing the value
prop we check if the value is undefined
and then pass another div
instead of the value that is just a loader and as soon as we get the value then it will render the value.
Create components/Stats/StatsCard.js
components/Stats/StatsCard.js
import { motion } from "framer-motion";
import { popUp } from "@content/FramerMotionVariants"; // ====> not created yet
export default function StatsCard({ title, value }) {
return (
<motion.div
className="flex-col justify-center py-4 px-7 rounded-md select-none transform origin-center bg-white dark:bg-darkSecondary shadow dark:shadow-md border border-transparent hover:border-gray-400 dark:hover:border-neutral-600 group"
variants={popUp}
>
<h1 className="text-3xl my-2 font-bold text-gray-600 dark:text-gray-300 group-hover:text-black dark:group-hover:text-white">
{value}
</h1>
<p className="text-base font-medium text-gray-500 group-hover:text-black dark:group-hover:text-white">
{title}
</p>
</motion.div>
);
}
Github Stats
Now we have implemented Dev.to Stats then Github stats will be very easy to implement. But first, we need to create an API route for Github Stats.
Create pages/api/stats/github.js
pages/api/stats/github.js
import { fetchGithub, getOldStats } from "@lib/github"; // ====> not created yet
export default async function handler(req, res) {
const {
public_repos: repos,
public_gists: gists,
followers,
} = await fetchGithub();
// it runs when user's api is exhausted, it gives the old data
if (repos === undefined && gists === undefined) {
const {
public_repos: repos,
public_gists: gists,
followers,
} = getOldStats();
return res.status(200).json({
repos,
gists,
followers,
});
}
res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);
return res.status(200).json({
repos,
gists,
followers,
});
}
The above code has two functions fetchGithub
and getOldStats
. Github API has limited request. So it might exhausted, then we need to take care of that by using mock response (old static data).
fetchGithub
: it returns the new data from githubgetOldStats
: it returns the mock response
Create lib/github.js
:
lib/github.js
const tempData = {
login: "j471n",
id: 55713505,
/* ..........other keys...... */
};
// its for /api/stats/github
export async function fetchGithub() {
return fetch("https://api.github.com/users/j471n").then((res) => res.json());
}
// its for getting temporary old data
export function getOldStats() {
return tempData;
}
You can get your mock response by typing https://api.github.com/users/<your-username>
in the browser.
Now that our API has been created let's call it inside pages/stats.js
:
pages/stats.js
/* ..............Other modules........... */
import StatsCard from "@components/Stats/StatsCard"; // ====> not created yet
export default function Stats() {
const { data: devto } = useSWR("/api/stats/devto", fetcher);
const { data: github } = useSWR("/api/stats/github", fetcher);
const stats = [
/* ...previous blog stats key...... */
/* Following code is added new */
{
title: "Github Repos",
value: github?.repos,
},
{
title: "Github Gists",
value: github?.gists,
},
{
title: "Github Followers",
value: github?.followers,
},
];
return (
<>
{/* .......old code...... */}
{/* We don't need to change something here it's all good here */}
</>
);
}
That's all you need to Create the First section of the stats Page. Now we will move to the next section which is Top Streams
Top Streams
In this section, I'll add my top Streams from Spotify. I have already given a little intro about Spotify at the beginning of this article in the Footer section where we add the current playing song support. The top stream section will look like this:
pages/stats.js
/* ..............Other modules........... */
import Track from "@components/Stats/Track"; // not created yet
export default function Stats() {
/* ...other api requests.... */
const { data: topTracks } = useSWR("/api/stats/tracks", fetcher);
return (
<>
{/* .......old code...... */}
{/* .........Blogs and github stats......... */}
{/* Spotify top songs */}
<div className="font-barlow">
<AnimatedHeading
variants={fromLeftVariant}
className="text-3xl sm:text-4xl capitalize font-bold text-neutral-900 dark:text-neutral-200"
>
My Top streams songs
</AnimatedHeading>
<AnimatedText
variants={popUpFromBottomForText}
className="mt-4 text-gray-500"
>
<span className="font-semibold">
{topTracks && topTracks[0].title}
</span>{" "}
is the most streamed song of mine. Here's my top tracks on Spotify
updated daily.
</AnimatedText>
<motion.div
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="flex flex-col my-10 gap-0 font-barlow"
>
{topTracks?.map((track, index) => (
<Track
key={index}
id={index}
track={track}
url={track.url}
title={track.title}
coverImage={track.coverImage.url}
artist={track.artist}
/>
))}
</motion.div>
</div>
{/* ............. */}
</>
);
}
Add the following code to pages/stats.js
. In the above code we just fetch the /api/stats/tracks
it returns the top 10 most streamed songs, then we render those by using the Track
Component. We have not created them yet. So let's create them.
Create pages/api/stats/tracks.js
:
pages/api/stats/tracks.js
import { topTracks } from "../../../lib/spotify";
export default async function handler(req, res) {
const response = await topTracks();
const { items } = await response.json();
const tracks = items.slice(0, 10).map((track) => ({
title: track.name,
artist: track.artists.map((_artist) => _artist.name).join(", "),
url: track.external_urls.spotify,
coverImage: track.album.images[1],
}));
res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);
return res.status(200).json(tracks);
}
In the above code, I am getting data from topTracks
function and then extracting the main info that I need. Let's create topTracks
:
lib/spotify.js
/* .........Other methods........ */
export const topTracks = async () => {
const { access_token } = await getAccessToken();
return fetch("https://api.spotify.com/v1/me/top/tracks", {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
};
That's it for this. Now we need to create Track
Component:
Create components/Stats/Track.js
components/Stats/Track.js
import Image from "next/image";
import Link from "next/link";
import { fromBottomVariant } from "../../content/FramerMotionVariants";
import { motion } from "framer-motion";
export default function Track({ url, title, artist, coverImage, id }) {
return (
<Link href={url} passHref>
<motion.a
variants={fromBottomVariant}
href={url}
className="bg-gray-100 hover:bg-gray-200 dark:bg-darkPrimary hover:dark:bg-darkSecondary border-l first:border-t border-r border-b border-gray-300 dark:border-neutral-600 p-4 font-barlow flex items-center gap-5 overflow-hidden relative xs:pl-16 md:!pl-20 "
rel="noreferrer"
target="_blank"
>
<div className="absolute left-4 md:left-6 text-xl text-gray-500 transform origin-center font-inter tracking-wider hidden xs:inline-flex">
#{id + 1}
</div>
<div className="relative w-12 h-12 transform origin-center">
<Image
src={coverImage}
width={50}
height={50}
layout="fixed"
alt={title}
quality={50}
></Image>
</div>
<div>
<h2 className="text-base md:text-xl text-gray-900 dark:text-white font-semibold transform origin-left font-barlow">
{title}
</h2>
<p className="transform origin-left text-gray-500 text-xs sm:text-sm md:text-base line-clamp-1">
{artist}
</p>
</div>
</motion.a>
</Link>
);
}
That's all you need to create The Top Stream Section.
Top Artists
This section contains my top stream artists. It looks like this:
Let's just add the following code in your pages/stats.js
pages/stats.js
/* ..............Other modules........... */
import Artist from "@components/Stats/Artist"; // not created yet
export default function Stats() {
/* ...other api requests.... */
const { data: artists } = useSWR("/api/stats/artists", fetcher);
return (
<>
{/* .........Blogs and github stats......... */}
{/* ...........Spotify top songs.......... */}
<div className="font-barlow">
<AnimatedHeading
variants={fromLeftVariant}
className="text-3xl sm:text-4xl capitalize font-bold text-neutral-900 dark:text-neutral-200"
>
My Top Artists
</AnimatedHeading>
<AnimatedText className="mt-4 text-gray-500">
My most listened Artist is{" "}
<span className="font-semibold">{artists && artists[0].name}</span> on
Spotify.
</AnimatedText>
<motion.div
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="flex flex-col my-10 gap-0 font-barlow"
>
{artists?.map((artist, index) => (
<Artist
key={index}
id={index}
name={artist.name}
url={artist.url}
coverImage={artist.coverImage.url}
followers={artist.followers}
/>
))}
</motion.div>
</div>
{/* ............. */}
</>
);
}
Add the following code to pages/artists.js
. In the above code we just fetch the /api/stats/artists
it returns the top 5 most streamed artists then we render those by using the Artist
Component. We have not created them yet. So let's create them.
Create pages/api/stats/artists.js
:
pages/api/stats/artists.js
import { topArtists } from "../../../lib/spotify";
export default async function handler(req, res) {
const response = await topArtists();
const { items } = await response.json();
const artists = items.slice(0, 5).map((artist) => ({
name: artist.name,
url: artist.external_urls.spotify,
coverImage: artist.images[1],
followers: artist.followers.total,
}));
res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);
return res.status(200).json(artists);
}
In the above we also fetch the data using topArtists
function, let's create it inside lib/spotify.js
:
lib/spotify.js
/*.......other functions/modules....... */
export const topArtists = async () => {
const { access_token } = await getAccessToken();
return fetch("https://api.spotify.com/v1/me/top/artists", {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
};
Now as it has been add we need one more thing to add and that is Artist
component:
Create components/Stats/Artist.js
components/Stats/Artist.js
import Image from "next/image";
import Link from "next/link";
import { fromBottomVariant, popUp } from "../../content/FramerMotionVariants";
import { motion } from "framer-motion";
export default function Track({ name, url, coverImage, followers, id }) {
return (
<Link href={url} passHref>
<motion.a
variants={fromBottomVariant}
href={url}
className="bg-gray-100 hover:bg-gray-200 dark:bg-darkPrimary hover:dark:bg-darkSecondary border-l first:border-t border-r border-b border-gray-300 dark:border-neutral-600 p-4 font-barlow flex items-center gap-5 overflow-hidden"
rel="noreferrer"
target="_blank"
>
<div className="text-xl text-gray-500 transform origin-center font-inter tracking-wider hidden xs:inline-flex">
#{id + 1}
</div>
<div
variants={popUp}
className="relative w-12 md:w-24 h-12 md:h-24 transform origin-center"
>
<Image
className="rounded-full"
src={coverImage}
width={100}
height={100}
layout="responsive"
alt={name}
quality={50}
></Image>
</div>
<div>
<h2
variants={popUp}
className="text-base sm:text-lg md:text-xl xl:text-2xl text-gray-900 dark:text-white font-semibold md:font-bold transform origin-left font-barlow"
>
{name}
</h2>
<p
variants={popUp}
className="transform origin-left text-gray-500 text-xs sm:text-sm md:text-base md:font-medium line-clamp-1"
>
{followers.toLocaleString()} Followers
</p>
</div>
</motion.a>
</Link>
);
}
This is it your stats page is ready to go. It was a lot to take in so let's look at the final result of pages/stats.js
:
pages/stats.js
import React from "react";
import useSWR from "swr";
import { motion } from "framer-motion";
import {
FadeContainer,
fromLeftVariant,
popUpFromBottomForText,
} from "@content/FramerMotionVariants";
import fetcher from "@lib/fetcher";
import MetaData from "@components/MetaData";
import PageTop from "@components/PageTop";
import StatsCard from "@components/Stats/StatsCard";
import Track from "@components/Stats/Track";
import Artist from "@components/Stats/Artist";
import AnimatedHeading from "@components/FramerMotion/AnimatedHeading";
import AnimatedText from "@components/FramerMotion/AnimatedText";
import pageMeta from "@content/meta";
export default function Stats() {
const { data: topTracks } = useSWR("/api/stats/tracks", fetcher);
const { data: artists } = useSWR("/api/stats/artists", fetcher);
const { data: devto } = useSWR("/api/stats/devto", fetcher);
const { data: github } = useSWR("/api/stats/github", fetcher);
const stats = [
{
title: "Total Posts",
value: devto?.posts.toLocaleString(),
},
{
title: "Blog Followers",
value: devto?.followers.toLocaleString(),
},
{
title: "Blog Reactions",
value: devto?.likes.toLocaleString(),
},
{
title: "Blog Views",
value: devto?.views.toLocaleString(),
},
{
title: "Blog Comments",
value: devto?.comments.toLocaleString(),
},
{
title: "Github Repos",
value: github?.repos,
},
{
title: "Github Gists",
value: github?.gists,
},
{
title: "Github Followers",
value: github?.followers,
},
];
return (
<>
<MetaData
title={pageMeta.stats.title}
description={pageMeta.stats.description}
previewImage={pageMeta.stats.image}
keywords={pageMeta.stats.keywords}
/>
<section className="pageTop font-inter">
<PageTop pageTitle="Statistics">
These are my personal statistics about my Dev.to Blogs, Github and Top
Streamed Music on Spotify.
</PageTop>
{/* Blogs and github stats */}
<motion.div
className="grid xs:grid-cols-2 sm:!grid-cols-3 md:!grid-cols-4 gap-5 my-10"
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{stats.map((stat, index) => (
<StatsCard
key={index}
title={stat.title}
value={
stat.value === undefined ? (
<div className="w-28 h-8 rounded-sm bg-gray-300 dark:bg-neutral-700 animate-pulse" />
) : (
stat.value
)
}
/>
))}
</motion.div>
{/* Spotify top songs */}
<div className="font-barlow">
<AnimatedHeading
variants={fromLeftVariant}
className="text-3xl sm:text-4xl capitalize font-bold text-neutral-900 dark:text-neutral-200"
>
My Top streams songs
</AnimatedHeading>
<AnimatedText
variants={popUpFromBottomForText}
className="mt-4 text-gray-500"
>
<span className="font-semibold">
{topTracks && topTracks[0].title}
</span>{" "}
is the most streamed song of mine. Here's my top tracks on Spotify
updated daily.
</AnimatedText>
<motion.div
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="flex flex-col my-10 gap-0 font-barlow"
>
{topTracks?.map((track, index) => (
<Track
key={index}
id={index}
track={track}
url={track.url}
title={track.title}
coverImage={track.coverImage.url}
artist={track.artist}
/>
))}
</motion.div>
</div>
{/* Spotify top Artists */}
<div className="font-barlow">
<AnimatedHeading
variants={fromLeftVariant}
className="text-3xl sm:text-4xl capitalize font-bold text-neutral-900 dark:text-neutral-200"
>
My Top Artists
</AnimatedHeading>
<AnimatedText className="mt-4 text-gray-500">
My most listened Artist is{" "}
<span className="font-semibold">{artists && artists[0].name}</span>{" "}
on Spotify.
</AnimatedText>
<motion.div
variants={FadeContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="flex flex-col my-10 gap-0 font-barlow"
>
{artists?.map((artist, index) => (
<Artist
key={index}
id={index}
name={artist.name}
url={artist.url}
coverImage=