如何使用 Next.js 制作我的投资组合

2025-05-28

如何使用 Next.js 制作我的投资组合

每个人都需要建立一个作品集或个人网站,以便潜在雇主更好地了解你的作品。我认为每个人都应该创建一个,即使它很简单,或者没什么可展示的。拥有一个总是好的。

我们将展示我用 Next.js 构建的作品集,其中包含许多内容(稍后我们将进行探讨)。闲话少叙,我们开始吧。

最初,我脑子里并没有一个很棒的设计。所以我直接开始写代码,结果比我预想的要好(相信我,我之前还以为会更糟)。你会看到我的作品集有两个版本。一个是旧版,一个是新版。

目录

旧版本

以前,我只有四个主要页面:首页(关于我)、技能、博客和项目。我使用Dev.to API来获取我的博客及其数据。

主页

主页

旧主页上有七个部分:关于我、主要技能、近期博客、认证、项目、常见问题解答和联系方式。它没有显示所有博客和项目,而是只显示 10 张卡片。

技能

技能

技能页面有一个进度条,显示我对该语言或技能的掌握程度,并附有简短的描述。

博客

博客

博客里有很多东西。首先,这里有一个搜索栏,然后是博客排序系统。之后,你会看到一个博客卡片。

分类系统

当鼠标悬停在博客卡片上时,您会看到两个选项,viewdev.to,以及博客的简要说明。

博客

点击“查看”按钮后,系统会将你带到博客页面。页面内容如下:

博客

项目

项目

在项目中,只有三个按钮,通过这些按钮您可以直接转到 GitHub 链接 Live Project,并可以共享它。

这是我以前的投资组合。您也可以分享您对这个投资组合的见解。我期待听到您的意见。

当前版本

当前版本的投资组合包含很多内容。我们将逐一讲解我是如何实现它们的。现在就开始构建吧。

访问j471n.in查看我的投资组合的当前版本

设置项目

创建项目

如果您尚未设置 Next.js 项目,请先创建一个。最常用的方法是使用“创建 Next App”。

npx create-next-app my-project
cd my-project
Enter fullscreen mode Exit fullscreen mode

安装 Tailwind CSS

通过 npm安装tailwindcss及其对等依赖项,然后运行 ​​init 命令生成tailwind.config.jspostcss.config.js

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

将 Tailwind 指令添加到你的 CSS

@tailwind将Tailwind 每一层的指令添加到您的./styles/globals.css文件中。

/* file : global.css */

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

配置模板路径

在您的文件中添加所有模板文件的路径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"),
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

我将使用pnpm作为包管理器,而不是 npm。您可以根据自己的喜好使用 npm 或 yarn。

现在我们需要安装刚刚添加到的 tailwindcss 插件tailwind.config.js

pnpm install @tailwindcss/line-clamp @tailwindcss/typography tailwind-scrollbar-hide
Enter fullscreen mode Exit fullscreen mode

@tailwindcss/typography将用于设计博客风格。

启动服务器

使用 运行构建过程。你的项目将在http://localhost:3000pnpm run dev上可用。

npm run dev
Enter fullscreen mode Exit fullscreen mode

设置 `next.config.js`

安装@next/bundle-analyzer

pnpm i @next/bundle-analyzer next-pwa
Enter fullscreen mode Exit fullscreen mode
/* Filename: 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
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

设置 jsconfig.json

设置jsconfig.json以便更轻松地导入。您无需../../../..再使用,只需使用@即可引用任何文件夹。

json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["components/*"],
      "@lib/*": ["lib/*"],
      "@utils/*": ["utils/*"],
      "@content/*": ["content/*"],
      "@styles/*": ["styles/*"],
      "@context/*": ["context/*"],
      "@layout/*": ["layout/*"],
      "@hooks/*": ["hooks/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

禁用一些 ESLint 规则

//file : .eslintrc.json
{
  "extends": "next/core-web-vitals",
  "rules": {
    "react/no-unescaped-entities": 0,
    "@next/next/no-img-element": "off"
  }
}
Enter fullscreen mode Exit fullscreen mode

添加暗黑模式支持

这只是使用 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;
};
Enter fullscreen mode Exit fullscreen mode

_app.js现在用以下方式包装DarkModeProvider


// _app.js

import "@styles/globals.css";
import { DarkModeProvider } from "@context/darkModeContext";

function MyApp({ Component, pageProps }) {
  return (
   {/* Adding Darkmode Provider */}
    <DarkModeProvider>
        <Component {...pageProps} />
    </DarkModeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

创建布局

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

不用担心,我们会逐一创建它们:

创建导航栏

这就是我们要创建的:
导航栏

在构建 Navbar 之前,我们需要先安装一些依赖项:

pnpm install react-icons framer-motion
Enter fullscreen mode Exit fullscreen mode

我们先来设置路由。我使用了一个静态数组,可以在其中添加新路由,它会自动在导航栏中显示:

// file: utils/utils.js

export const navigationRoutes = [
  "home",
  "about",
  "stats",
  "utilities",
  "blogs",
  "certificates",
  "projects",
  "newsletter",
  "rss",
];
Enter fullscreen mode Exit fullscreen mode

这些是我们要创建的路由。TopNavbar现在让我们看一下组件:

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

我们导入了很多东西,但还没有创建FramerMotionVariants。那就让我们来创建它们吧:

// file: 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",
  },
};
Enter fullscreen mode Exit fullscreen mode

这些也是适用于桌面和移动设备的导航栏动画的变体。

了解 Framer 运动

现在,回到TopNavbar

// file: 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>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • 这是我使用的完整文件TopNavbar。我scroll给窗口添加了一个事件,只是为了给导航栏添加阴影,并在移动设备上显示我的名字。
  • 我还通过在移动导航栏处于活动/打开状态时调用该函数来锁定滚动lockScroll。这样用户就无法滚动了。
  • navigationRoutes.slice(0, 7)为了让导航栏看起来简洁明了,我只显示了 7 条导航路线( )。我会把剩下的添加到页脚部分。
  • 导航栏右侧还有一个按钮,可以从暗模式切换到亮模式。

结果
现在当您滚动时,名称将显示在移动设备上,这是因为control我们之前添加了:

姓名

完成此操作后,当您打开移动导航时,它将呈现如下动画:

侧边导航栏动画

TopNavbar有四个组成部分:

  • TopNavbar:主导航面板
  • 汉堡包:左上角的汉堡按钮
  • MobileMenu:移动导航列表或菜单
  • NavItem:桌面模式的导航链接/项目

我们已经添加TopNavbarLayout组件。

添加滚动到顶部按钮

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

ScrollToTopButton当您位于页面顶部或底部时,它不会显示。只有当您的视口位于 之间时,它才会显示10%-95%。执行此操作后,它将看起来像这样:

滚动到顶部按钮

我还写了一篇文章来解释,您也可以阅读:

创建页脚

页脚可能有点复杂,因为它使用了Spotify API。这就是我们要构建的:

页脚

Spotify 集成会显示我当前是否正在播放任何歌曲。页脚会根据此状态进行更改。下图展示了这两种状态。我们稍后会创建它。

页脚设计

页脚底部有一个二维码按钮,我将在下一部分介绍。

创建components/Footer.js

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

安装 SWR

首先,我们需要安装swr。SWR 是一种策略,首先从缓存中返回数据(过时),然后发送获取请求(重新验证),最后获取最新数据。

pnpm i swr
Enter fullscreen mode Exit fullscreen mode

SWR 使用的fetcher函数只是原生函数的包装器fetch。您可以访问他们的文档

创建获取函数

// file : lib/fetcher.js

export default async function fetcher(url) {
  return fetch(url).then((r) => r.json());
}
Enter fullscreen mode Exit fullscreen mode

创建社交媒体链接

正如您在页脚中看到的,我们还有一些尚未创建的社交媒体链接。接下来我们来创建它:

// file: 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",
  },
];
Enter fullscreen mode Exit fullscreen mode

我也导入了 React 图标,也许你想在文本中添加图标。如果你不想,可以删除图标部分。

现在我们几乎已经完成了所有的事情,让我们继续Footer.js

// file : components/Footer.js

/*......previous code......*/

export default function Footer({ setShowQR, showQR }) {}
Enter fullscreen mode Exit fullscreen mode

setShowQRshowQR为道具,触发底部二维码按钮。

// file : components/Footer.js

/*......previous code......*/

export default function Footer({ setShowQR, showQR }) {
  const { data: currentSong } = useSWR("/api/now-playing", fetcher);
}
Enter fullscreen mode Exit fullscreen mode

现在我们将使用useSWR获取 Next.js API 路由,如果用户正在播放,它将返回当前正在播放的歌曲,否则很简单false

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

它会返回我正在播放的歌曲及其详细信息。但我们还没有创建/api/now-playing路线。是吗?那就创建吧。

添加正在播放的 Spotify 路线

如果您不熟悉 Nextjs API 路由,那么我强烈建议您先查看文档

创建pages/api/now-playing.js文件:

export default async function handler(req, res) {}
Enter fullscreen mode Exit fullscreen mode

现在最复杂的部分是 Spotify 集成。不过不用担心,我有一个博客详细介绍了如何实现这一点,您可以在这里阅读(这是必读的):

实现 Spotify API 集成后,您将获得三样东西:

  • SPOTIFY客户端ID=
  • SPOTIFY_CLIENT_SECRET=
  • SPOTIFY_REFRESH_TOKEN=

将这些.env.local连同它们的值一起添加到您的服务器并重新启动服务器。

现在我们将创建一个从服务器上获取数据的函数。

创造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}`,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

在上面的代码中我们只是添加了两个函数:

  • getAccessToken:这将使用refresh_token并返回access_token对其他 API 端点的授权。
  • currentlyPlayingSong:获取当前播放的歌曲并返回 JSON 数据。

您可以在其文档中了解有关 Spotify 授权的更多信息

现在,我们已经创建了一个函数来获取数据。让我们在 API 路由中调用它。

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

很简单吧?现在,我们添加了 API 路由(/api/now-playing)。让我们调用它Footer并获取当前正在播放的歌曲。

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

如果你访问http://localhost:3000/api/now-playing,你会看到返回了一些数据。如果你没有搞错的话。(如果搞错了,请尝试再次阅读“关于”部分)

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

在关于代码中,我们还没有实现三件事:

  • FooterLink:在页脚中显示链接
  • NotPlaying:当我没有播放歌曲时显示此组件
  • WhenPlaying:播放歌曲时显示此组件

让我们在 中添加这些组件components/Footer.js。您可以为它们创建单独的组件,这取决于您。我已将它们添加到Footer组件中:

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

如果您对结果感到困惑,那么以下是页脚的完整代码:

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

添加 QRCodeContainer

到现在为止,你可能还在疑惑为什么底部会有二维码图标。这是因为这个按钮会切换showQR显示当前网址的二维码。所以,我们直接创建它吧。Footer.jsQRCodeContainer

安装依赖项

我们需要安装react-qr-codereact-ripples来分别生成二维码和显示涟漪效果。

pnpm i react-qr-code react-ripples
Enter fullscreen mode Exit fullscreen mode

创建 useWindowLocation Hook

我们需要创建一个钩子来返回当前窗口位置。

创建 *hooks/useWindowLocation.js

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

创建useWindowLocation钩子后,让我们创建QRCodeContainer

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

实现我上面提到的所有内容后,您就创建了一个页脚,它看起来像这样:

页脚 gif

我们的布局现在已经完成了。如果你还没有添加这个布局,请_app.js立即添加:

/* File: pages/_app.js */

/* previous code */
import Layout from "@layout/Layout";

function MyApp({ Component, pageProps }) {
  return (
    <DarkModeProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </DarkModeProvider>
  );
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

添加 NProgressbar

NProgress 是一款小型 JavaScript 插件,用于创建纤薄的纳米级进度条,并具有逼真的涓流动画,让用户感受到正在发生某事。该插件会在网页顶部显示进度条。

安装 nprogress

运行以下命令来安装nprogress

pnpm i nprogress
Enter fullscreen mode Exit fullscreen mode

使用 nprogress

安装后nprogress我们需要在我们的中使用它pages/_app.js

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

这就是我们需要做的全部工作。上面的代码useEffect会在路由变化时运行,并据此处理 NProgress。

添加字体

在项目开始时,我们添加了一些字体tailwind.config.js


/* File: tailwind.config.js */

/* ...... */
  fontFamily: {
      inter: ["Inter", "sans-serif"],
      sarina: ["Sarina", "cursive"],
      barlow: ["Barlow", "sans-serif"],
      mono: ["monospace"],
  },
/* ...... */
Enter fullscreen mode Exit fullscreen mode

现在我们需要安装它们。有很多方法可以使用这些字体,例如:

  • 通过使用@import
  • 通过使用link

但我们不会以这种方式使用它们。我们将把这些字体下载到本地,然后再使用它们。这是因为如果浏览器每次用户访问你的网站时都获取字体,那么这需要时间。这需要时间,而通过在本地使用它们,我们可以使用Cache-Control。这将缓存字体并加快加载速度。

我正在使用Vercel来托管我的网站,我想你也应该这样做,因为它可以提供Cache-Control我们即将实现的功能。

下载字体

太好了,您不必逐个下载它们。请按照以下步骤操作:

  • 从这里下载这些字体作为 zip 文件
  • 解压zip文件
  • 如果没有文件夹,请在文件夹fonts创建一个文件夹public
  • 将所有字体放入该文件夹中,如下所示:
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
Enter fullscreen mode Exit fullscreen mode

现在您已将字体下载并保存到正确的位置。现在让我们添加它们。

链接字体

在 `_document.js` 中添加字体

现在我们需要链接或添加字体pages/_document.js。如果你没有,请创建一个:

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

在 global.css 中添加字体

我们需要使用@font-face字体优化:

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

将字体添加到 vercel.json

这一步很重要,因为我们使用Vercel作为托管平台,并且需要使用Cache-Control。因此vercel.json就像下面的代码一样:

/* File: 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"
        }
      ]
    }
  ],
}
Enter fullscreen mode Exit fullscreen mode

这就是我们在项目中添加字体所需要做的全部工作。

添加样式

我正在使用TailwindCSS来设置项目的样式,但我们需要一些自定义样式,这些样式将在 中进行styles/global.css。以下样式和类将在整个项目中使用。因此,只需将以下内容复制粘贴到您的 中即可styles/global.css(不要添加重复项):

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

添加 SEO 支持

我们需要使用元数据来提升 SEO。因此,在创建页面和作品集之前,我们先添加 SEO 支持。

创建元数据

我们需要元数据添加到网站以提升 SEO。因此,我创建了一个静态数据文件,其中包含标题、描述、图片(页面共享时)和关键词。复制并粘贴以下代码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",
  },
};
Enter fullscreen mode Exit fullscreen mode

创建元数据组件

我们需要一个组件,它可以在每个使用上述元数据的页面上使用,并将其添加到head页面的顶部。还有一个原因,我们需要将此应用创建为 PWA。将上述代码复制并粘贴到components/MetaData.js

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

主页

我们已经添加了启动项目所需的几乎所有内容。首先,我们来创建主页。主页包含四个部分:

  • 用户配置文件
  • 技能
  • 近期文章
  • 联系方式(取得联系)

我们将逐一创建它们。

用户配置文件

这是主页的第一部分。它看起来应该是这样的:

个人资料部分

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

上面的代码只是将 添加meta-data到页面。我们还没有创建homeProfileImage。让我们快速创建它:

在里面utils/utils.js添加您的个人资料图片 URL(应为 png 或 jpg 格式)

/* File: utils/utils.js */

export const homeProfileImage = "https://imgur.com/mKrXwWF.png";
Enter fullscreen mode Exit fullscreen mode

现在一切顺利。我们只需要将 JSX 添加到“index.js让我们也添加它”中。

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

在上面的代码中,当点击“恢复”按钮时,我们会将用户引导到/resume尚未创建的路由。我们不会创建它,因为我们将用它vercel.json来重定向用户。让我们看看如何做到这一点:

/* File: vercel.json */

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "cleanUrls": true,
  "headers": [
    /* ......Fonts...... */
  ],
  "redirects": [
    {
      "source": "/home",
      "destination": "/"
    },
    {
      "source": "/resume",
      "destination": "/resume.pdf"
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

上面的代码显示我们将用户从 重定向/home//resume重定向到/resume.pdf。您只需将简历以 PDF 格式放在public目录中即可。稍后将如下所示:

my-project/
├── components
├── pages
└── public/
    ├── fonts/
    └── resume.pdf
Enter fullscreen mode Exit fullscreen mode

技能

这是主页的第二部分。它看起来应该是这样的:
技能

对于技能,我还使用了您可以操作的静态数据。

创造content/skillsData.js

/* File: 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..... */
];
Enter fullscreen mode Exit fullscreen mode

上述代码返回对象数组,每个对象包含三个键:

  • name:技能名称
  • 级别:您对该技能的了解程度(可选)
  • 固定:(真/假)如果为真,技能将显示在主屏幕上

我们需要创建两样东西:

  • getPinnedSkills:仅返回固定技能的函数
  • SkillSection:技能组件

创建 getPinnedSkills

创建lib/dataFetch.js并在其中添加以下代码:

/* File: lib/dataFetch.js */
import skills from "@content/skillsData";

export function getPinnedSkills() {
  return skills.filter((skill) => skill.pinned);
}
Enter fullscreen mode Exit fullscreen mode

就是这样,现在我们需要实现它们pages/index.js

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

getStaticProps在底部添加index.js并将getPinnedSkills其返回skills为我们可以在组件内部使用的道具Home

这里+显示了添加的内容,也-显示了删除的内容。

创建 SkillSection 组件

现在我们正在为技能创建一个单独的组件。

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

在上面的代码中,我们创建了一个grid带有图标的技能展示器。因为我们没有在 中添加图标content/skillData.js。所以这里我们选择的是图标(我知道这不是最好的方法)。我们添加了窗口悬停动画onMouseMove并将其移除onMouseLeave。我们稍后会创建它。在上面的代码中,我们还没有创建两样东西:

  • HomeHeading:项目的动画标题
  • WindowsAnimation:技能卡的 Windows 悬停动画

创建HomeHeading组件

您可以HomeHeading在组件文件夹中创建组件,但我pages/index.js在底部创建:

/* File: 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()....... */
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我们还没有创建。你可以在这里AnimatedHeading找到

创建WindowsAnimation

我们将为技能卡创建一个窗口悬停动画。为此创建一个文件lib/WindowsAnimation.js

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

它有两个函数:showHoverAnimation显示动画和removeHoverAnimation移除动画(移除光标)。实现后,它看起来像这样(仅适用于暗黑模式):

窗口悬停动画

现在我们只需要SkillSection导入pages/index.js

/* File: 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....... */
Enter fullscreen mode Exit fullscreen mode

近期文章

要实现此功能,我们需要创建一个博客。为此,我使用了MDX。我们将在本文后面创建博客页面时介绍这一点。或者,您也可以直接跳到“博客”部分。

接触

现在我们需要创建一个联系表单,以便任何人都可以通过邮件联系我。我们将使用Email.js发送邮件。您可以查看他们的文档进行设置(设置非常简单)。如果您愿意,也可以使用其他方式,完全由您决定。我们的联系表单如下所示:

联系部分

安装依赖项

在我们开始构建表单之前,您需要安装两个依赖项:

pnpm i react-toastify @emailjs/browser
Enter fullscreen mode Exit fullscreen mode

创建联系人组件

index.js首先在文件夹内创建一个文件名components/Contact

/* File: components/Contact/index.js */

export { default } from "./Contact";
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我们导入了Contact组件。让我们创建它:

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

上面的代码很简单。我们只是没有创建两件事:

  • AnimatedText:动画段落
  • ContactForm:表单组件

您可以在这里AnimatedText找到组件

创建 ContactForm 组件

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

在上面的代码中,我只是渲染了联系表单并通过 emailjs 发送电子邮件。每个字段都是必填的,但是当用户点击时,我会提交一个表单,CTRL + Enter因为它并不关心字段是否必填,所以我必须手动使用函数检查每个字段是否填写完整validateForm。如果邮件已发送,它会给出一个提示,如下所示:

联系演示

这是我们刚刚创建的主页。目前还没有“近期博客”板块。我们将在创建博客页面时添加。如果您感兴趣,可以跳转到“博客”板块,了解我们将如何实现该功能。

统计页面

统计页面包含我的 dev.to 博客、GitHub 和 Spotify 的个人统计数据。其外观如下图所示:

统计页面

上图显示统计信息页面包含三个部分:

  • 博客统计
  • 热门流媒体
  • 顶级艺术家

我们将逐一创建它们。但首先,在文件夹stats.js中创建一个名为的文件pages

/* File: pages/stats.js */

import React from "react";

export default function Stats() {
  return <></>;
}
Enter fullscreen mode Exit fullscreen mode

博客和 GitHub 统计信息

在本节中,我们将创建博客和 GitHub 统计卡,如下所示:

统计-1

让我们首先添加 Header。我们将创建一个名为的组件,PageTop它将在整个项目中使用。

创造components/PageTop.js

/* File: components/PageTop.js */

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

在这里,我们将使用pageTitle作为 prop,并将children作为项目描述。我们还没有创建fromLeftVariantopacityVariant。那么,让我们开始吧:

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

现在将此PageTop组件添加到pages/stats.js

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

在上面的代码中,我已经导入了模块并渲染了MetaDataPageTop

Dev.to 统计

现在我们已经添加了页面顶部部分,让我们来实现 Dev.to 统计信息。为此,我将使用Dev.to API来获取我的博客统计信息。但是这个 API 有一些限制,例如它在一个页面上最多只能显示 1000 个对象(不是全部)。所以我们将解决这个问题。

如果您有兴趣,我还写了一篇关于 Dev.to API 的博客,请访问以下链接:

我还将使用 Next.js API 路由来获取数据。因此,让我们创建pages/api/stats/devto.js一个文件。这将是返回统计信息的 API 路由。

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

我们使用两个函数allFollowersallPosts分别返回我所有的关注者和我发布的所有博客/帖子。然后,我们迭代每篇帖子,提取点赞(公众反应)、浏览量和评论。之后,我设置了缓存,这样浏览器就不会一遍又一遍地获取数据。我们每 43200 秒(也就是 12 小时)重新验证一次请求。

让我们在里面创建allFollowersallFollowers函数lib/devto.js

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

您需要添加NEXT_PUBLIC_BLOGS_API到您的.env.local服务器,然后重新启动服务器。如果您不知道如何获取 Dev.to API,请点击此处,然后转到页面底部生成 API。

上面的代码有两个额外的函数getPageOfFollowersgetPageOfPosts它们只是帮助我们获取一个页面的统计数据并请求 API,直到收到所有数据。

现在让我们将其获取到我们的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...... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

我创建了一个数组用于迭代和渲染,StatsCard稍后我们会创建它。在StatsCard传递valueprop 时,我们会检查值是否为真,如果为真undefined,则传递另一个值div,而不是传递该值,而这个值只是一个加载器,一旦我们获取到值,它就会渲染该值。

创造components/Stats/StatsCard.js

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

Github 统计信息

现在我们已经实现了Dev.to Stats,那么 Github Stats 的实现就非常容易了。但首先,我们需要为 Github Stats 创建一个 API 路由。

创造pages/api/stats/github.js

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

上面的代码有两个函数fetchGithubgetOldStats。Github API 的请求量有限,因此可能会耗尽,这时我们需要使用模拟响应(旧的静态数据)来解决这个问题。

  • fetchGithub:它返回来自 github 的新数据
  • getOldStats:它返回模拟响应

创造lib/github.js

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

https://api.github.com/users/<your-username>您可以通过在浏览器中输入来获取模拟响应。

现在我们的 API 已经创建好了,我们可以在里面调用它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 { 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  */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

这就是创建统计页面第一部分所需的全部内容。现在我们将进入下一部分,即热门信息流

热门直播

在本节中,我将添加来自 Spotify 的热门流媒体。在本文开头的页脚部分,我已经对 Spotify 做了一些介绍,我们添加了当前正在播放的歌曲支持。热门流媒体部分如下所示:
热门流

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

将以下代码添加到pages/stats.js。在上面的代码中,我们只是获取了它,/api/stats/tracks它返回了播放次数最多的前 10 首歌曲,然后我们使用组件渲染这些歌曲Track。我们还没有创建它们。所以让我们来创建它们。

创造pages/api/stats/tracks.js

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

在上面的代码中,我从topTracks函数中获取数据,然后提取我需要的主要信息。让我们创建topTracks

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

就是这样。现在我们需要创建Track组件:

创造components/Stats/Track.js

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

这就是创建“顶级流部分”所需的全部内容。

顶级艺术家

此部分包含我最喜爱的流媒体艺术家。它看起来像这样:

顶级艺术家

我们只需在你的pages/stats.js

/* File: 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>

      {/* ............. */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

将以下代码添加到pages/artists.js。在上面的代码中,我们只是获取了 ,/api/stats/artists它返回了流媒体播放量最高的 5 位艺术家,然后我们使用Artist组件渲染它们。我们还没有创建它们。所以让我们来创建它们。

创造pages/api/stats/artists.js

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

在上面我们还使用函数获取数据topArtists,让我们在里面创建它lib/spotify.js

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

现在,由于已经添加了,我们还需要添加一个东西,那就是Artist组件:

创造components/Stats/Artist.js

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

就这样,你的统计页面就准备好了。这需要花很多功夫,让我们来看看最终结果pages/stats.js

/* File: 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={artist.coverImage.url}
                followers={artist.followers}
              />
            ))}
          </motion.div>
        </div>
      </section>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

所有艺术家和歌曲均可点击。点击后,将直接跳转到相应的艺术家或歌曲。

证书页面

此页面显示我拥有的证书数量。我可以从这里展示它们,如下所示:

证书页面

创建证书数据

我使用静态证书数据,稍后可以进一步操作。我们先创建数据:

/* File: content/certificatesData.js */

/* Organizations */
const LinkedIn = {
  orgName: "LinkedIn",
  orgLogo: "https://imgur.com/k0cPDY6.png",
};
const Udemy = {
  orgName: "Udemy",
  orgLogo: "https://imgur.com/rvn6djH.png",
};
/* ....more objects of organization..... */

/* Certificates Data */

module.exports = [
  {
    title: "Become a Software Developer",
    issuedDate: "Oct 25, 2020",
    issuedBy: LinkedIn,
    urls: {
      pdfURL:
        "https://drive.google.com/file/d/1MXTze2mXB7b8Kod7Pk6Q1BTNb1l0OYn3/view?usp=sharing",
    },
    pinned: true,
  },
  /* .......other objects........ */
];
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,您将看到两个部分,一个部分包含多个包含orgLogo和 的组织对象orgName。另一个部分包含主要的证书数据,例如标题、issuedBy、URLs 和 issuedDate 等(您可以创建自己的数据)。

创建证书页面

现在我们有了数据,让我们创建一个证书页面或路线。

创造pages/certificates.js

/* File: pages/certificates.js */

/* importing modules */
import MetaData from "@components/MetaData";
import { popUpFromBottomForText } from "@content/FramerMotionVariants";
import certificatesData from "@content/certificatesData";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import PageTop from "@components/PageTop";
import pageMeta from "@content/meta";

export default function Certificates() {
  return (
    <>
      <MetaData
        title={pageMeta.certificates.title}
        description={pageMeta.certificates.description}
        previewImage={pageMeta.certificates.image}
        keywords={pageMeta.certificates.keywords}
      />

      <section className="pageTop">
        <PageTop pageTitle="Certificates">
          I've participated in many contests, courses and test and get certified
          in many skills. You can find the certificates below.
        </PageTop>

        <div className="flex flex-col gap-3 font-inter px-5">
          {certificatesData.map((cer, index) => {
            return (
              <AnimatedDiv
                className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-4  p-3 rounded-lg bg-white shadow dark:bg-darkSecondary/50"
                variants={popUpFromBottomForText}
                key={index}
              >
                <div className="flex items-center gap-3">
                  <div className="relative flex items-center justify-center">
                    <Image
                      width={40}
                      height={40}
                      src={cer.issuedBy.orgLogo}
                      alt={cer.issuedBy.orgName}
                      quality={50}
                      objectFit="contain"
                      layout="fixed"
                      placeholder="blur"
                      blurDataURL={cer.issuedBy.orgLogo}
                    />
                  </div>
                  <div className="flex flex-col ">
                    <h3 className="font-semibold text-sm sm:text-base md:text-lg text-neutral-900 dark:text-neutral-200">
                      {cer.title}
                    </h3>
                    <p className="text-xs text-gray-500">
                      {cer.issuedBy.orgName}
                    </p>
                  </div>
                </div>
                <div className="flex items-center gap-5 text-sm justify-between">
                  <p className="text-gray-500 text-sm">{cer.issuedDate}</p>
                  <Link href={cer.urls.pdfURL} passHref>
                    <motion.a
                      href={cer.urls.pdfURL}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="px-4 py-1 rounded-md bg-neutral-200 dark:bg-black shadow dark:text-white transform duration-200 font-medium  active:scale-90 lg:hover:bg-black lg:hover:text-white dark:lg:hover:bg-white dark:lg:hover:text-black"
                    >
                      View
                    </motion.a>
                  </Link>
                </div>
              </AnimatedDiv>
            );
          })}
        </div>
      </section>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

现在您的证书页面已准备就绪,您将看到已添加到静态数据的证书。如果出现问题,请再次检查代码,您可能会遗漏某些内容。您可能想知道是什么,那么您可以查看此处的AnimatedDiv代码。

项目页面

此页面展示了我参与过的项目。它看起来像这样:

项目页面

创建项目数据

为此,我再次使用静态数据,就像在证书页面上所做的那样。让我们快速创建它:

创造content/projectData.js

/* File: content/projectData.js */

module.exports = [
  {
    id: 15,
    name: "Google Docs with Next.js",
    coverURL: "https://imgur.com/bQkEGlb.png",
    description:
      "Next Google Docs is a web app which uses draft.js to create a document for you. It also uses Firebase to store all the user's data.",
    githubURL: "https://github.com/j471n/next-google-docs",
    previewURL: "https://google-next-docs.vercel.app/",
    tools: ["Next.js", "Tailwind CSS", "Firebase", "Draft.js", "Next Auth"],
    pinned: true,
  },

  /* ..............Other Projects......... */
];
Enter fullscreen mode Exit fullscreen mode

创建项目页面

由于现在我们刚刚创建了项目数据,让我们创建项目页面:

创造pages/projects.js

/* File: pages/projects.js */

import React from "react";
import { AnimatePresence } from "framer-motion";
import Project from "@components/Project"; // ====> not created yet
import Metadata from "@components/MetaData";
import PageTop from "@components/PageTop";
import { getProjects } from "@lib/dataFetch"; // ====> not created yet
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import { FadeContainer } from "@content/FramerMotionVariants";
import pageMeta from "@content/meta";

export default function Projects({ projects }) {
  return (
    <>
      <Metadata
        title={pageMeta.projects.title}
        description={pageMeta.projects.description}
        previewImage={pageMeta.projects.image}
        keywords={pageMeta.projects.keywords}
      />
      <section className="pageTop">
        <PageTop pageTitle="Projects">
          I've been making various types of projects some of them were basics
          and some of them were complicated. So far I've made{" "}
          <span className="font-bold text-gray-600 dark:text-gray-200">
            {projects.length}
          </span>{" "}
          projects.
        </PageTop>

        <AnimatedDiv
          variants={FadeContainer}
          className="grid grid-cols-1 gap-4 mx-auto md:ml-[20%] xl:ml-[24%]"
        >
          <AnimatePresence>
            {projects &&
              projects.map((project, index) => {
                if (project.name === "" && project.githubURL === "")
                  return null;
                return <Project key={index} project={project} />;
              })}
          </AnimatePresence>
        </AnimatedDiv>
      </section>
    </>
  );
}

export async function getStaticProps() {
  const projects = getProjects();
  return {
    props: {
      projects,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

我正在使用getStaticProps调用来获取数据,getProjects我们稍后会创建它,然后我们将其作为(主页面组件)projects中的道具,并使用该组件呈现数据。ProjectsProject

让我们getProjects在里面创建函数lib/dataFetch.js

/* File: lib/dataFetch.js */

export function getProjects() {
  return projects.reverse(); // reversing it so that we get the latest project first as I add new project at the end of the array.
}
Enter fullscreen mode Exit fullscreen mode

现在我们需要创建一个Project组件。

创造components/Project.js

/* File: components/Project.js */

import { BsGithub } from "react-icons/bs";
import { MdOutlineLink } from "react-icons/md";
import Link from "next/link";
import OgImage from "@components/OgImage"; // =========> not created yet

export default function Project({ project }) {
  return (
    <div className="card">
      <OgImage
        src={project.coverURL}
        alt={project.name}
        darkSrc={project.darkCoverURL}
      />

      <div className="flex flex-col justify-start gap-3">
        <h1 className="font-bold capitalize text-neutral-900 dark:text-neutral-200">
          {project.name}
        </h1>
        <p className="text-sm text-gray-400 dark:text-neutral-400 truncate-2">
          {project.description}
        </p>

        <div className="flex items-center gap-1 flex-wrap">
          {project.tools.map((tool, index) => {
            return (
              <span
                key={`${tool}-${index}`}
                className="bg-gray-100 dark:bg-darkPrimary text-gray-500 rounded px-2 py-1 text-xs"
              >
                {tool}
              </span>
            );
          })}
        </div>

        <div className="mt-auto p-2 w-fit flex items-center gap-4">
          <Link href={project.githubURL} passHref>
            <a
              title="Source Code on GitHub"
              target="_blank"
              rel="noopener noreferrer"
              href={project.githubURL}
              className="text-gray-500 hover:text-black dark:hover:text-white"
            >
              <BsGithub className="w-6 h-6 hover:scale-110 active:scale-90 transition-all" />
            </a>
          </Link>

          {project.previewURL && (
            <Link href={project.previewURL} passHref>
              <a
                title="Live Preview"
                target="_blank"
                rel="noopener noreferrer"
                href={project.previewURL}
                className="text-gray-500 hover:text-black dark:hover:text-white"
              >
                <MdOutlineLink className="w-6 h-6 hover:scale-110 active:scale-90 transition-all" />
              </a>
            </Link>
          )}
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

你可能想知道这到底是什么OgImage。它代表Open Graph Image,单位是1200x630像素。所有网站,例如 Twitter 和 LinkedIn,当有人在其网站上分享卡片时,都会显示此图像。因此,我创建了一个名为 的新组件OgImage,我们稍后也会在博客页面上使用它。让我们来创建它:

创造components/OgImage.js

import Image from "next/image";
import { useDarkMode } from "@context/darkModeContext";

function OgImage({ src, alt, darkSrc }) {
  const { isDarkMode } = useDarkMode();
  return (
    <div className="relative -mt-[35%] sm:-mt-0 md:-ml-[35%] w-full sm:w-1/2 md:w-8/12 shrink-0 rounded-xl overflow-hidden shadow-2xl before:absolute before:inset-0 dark:before:bg-black/20 before:z-10">
      <Image
        title={alt}
        alt={alt}
        src={darkSrc ? (isDarkMode ? darkSrc : src) : src}
        width={1200}
        height={630}
        layout="responsive"
        placeholder="blur"
        objectFit="cover"
        blurDataURL={darkSrc ? (isDarkMode ? darkSrc : src) : src}
        quality={50}
        className="lg:group-hover:scale-110 transition-all duration-300 backdrop-blur-xl"
      />
    </div>
  );
}

export default OgImage;
Enter fullscreen mode Exit fullscreen mode

它可以根据你的主题更改图片 URL(以备将来使用)。这就是创建项目页面所需的全部内容。

实用工具页面

这个页面很有意思,因为它展示了我使用的工具和应用程序。我再次使用静态数据来添加实用程序。页面如下所示:

实用页面

如您所见,它目前包含三个部分:系统、编码工具和软件/应用程序。为此,我将创建一个全局对象,其中包含这些子对象。对于图标,我正在使用,react-icons但有些图标缺失,因此我制作了支持明暗主题的自定义图标。我们稍后会创建它们。

让我们首先创建实用程序数据。

创建实用程序数据

/* File: content/utilitiesData.js  */

/* .....Importing Icons..... */
import {
  SiVisualstudiocode,
  SiSublimetext,
  SiMicrosoftedge,
  SiGooglechrome,
  SiReact,
  SiNextdotjs,
  SiTailwindcss,
  SiVercel,
  SiPrettier,
  SiPnpm,
  SiYarn,
  SiFigma,
  SiInsomnia,
  SiBitwarden,
  SiSpotify,
  SiObsstudio,
  SiGrammarly,
} from "react-icons/si";
import {
  BsFillPaletteFill,
  BsFillTerminalFill,
  BsWindows,
  BsGithub,
} from "react-icons/bs";
import { FaGitAlt, FaSearch } from "react-icons/fa";

import SVG from "@components/SVG"; //==> not created yet

const 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.",
  lastUpdate: "June 30, 2022",

  /* System */

  system: {
    title: "System",
    data: [
      {
        name: "VSCode",
        description: "Primary Code editor",
        Icon: SiVisualstudiocode,
        link: "https://code.visualstudio.com/download",
      },
      {
        name: "Andromeda",
        description: "VS Code theme",
        Icon: BsFillPaletteFill,
        link: "https://marketplace.visualstudio.com/items?itemName=EliverLara.andromeda",
      },

      /* .............Other........ */
    ],
  },

  /* Coding Tools */
  tools: {
    title: "Coding Tools",
    data: [
      {
        name: "React.js",
        description: "Primary Front-end library",
        Icon: SiReact,
        link: "https://reactjs.org/",
      },
      {
        name: "Next.js",
        description: "Primary Web Development Framework",
        Icon: SiNextdotjs,
        link: "https://nextjs.org/",
      },
      /* .............Other........ */
    ],
  },

  /* Software/Applications */

  software: {
    title: "Software/Applications",
    data: [
      {
        name: "Microsoft Todo",
        description: "To manage all my todos",
        Icon: SVG.MicrosoftToDo,
        link: "https://todo.microsoft.com/tasks/",
      },
      {
        name: "Raindrop.io",
        description: "Bookmark Manager",
        Icon: SVG.RainDrop,
        link: "https://raindrop.io/",
      },
      /* .............Other......... */
    ],
  },
};

export default utilities;
Enter fullscreen mode Exit fullscreen mode
  • 首先,我们使用导入图标,然后使用我们稍后创建的react-icons语句导入自定义 SVG 图标。import SVG from "@components/SVG"
  • 然后我们创建一个对象utilities来添加所有数据。它包含页面标题、页面描述以及我上次更新此列表的日期。
  • 它还包含多个对象system,,tools并且software您可以根据需要添加更多对象。
  • 在该software部分中,您可以看到我正在使用图标SVG.MicrosoftToDo,这些是我们现在将要创建的对象内的自定义图标。SVG.RainDropSVG

自定义 SVG 图标

创造components/SVG/index.js

/* File: components/SVG/index.js  */

import Ditto from "./Ditto";
import Flux from "./Flux";
import MicrosoftToDo from "./MicrosoftToDo";
import RainDrop from "./RainDrop";
import ShareX from "./ShareX";

const SVG = {
  Ditto,
  Flux,
  MicrosoftToDo,
  RainDrop,
  ShareX,
};
export default SVG;
Enter fullscreen mode Exit fullscreen mode

components/SVG在此示例中,我们将文件夹中的所有图标导入components/SVG/index.js并导出为对象。我使用 Figma 制作了所有图标,然后将它们导出为 SVG 文件并放在一个单独的文件中。让我们创建其他 SVG 图标。

同上

/* File: components/SVG/Ditto.js */

import { useDarkMode } from "@context/darkModeContext";

export default function Ditto() {
  const { isDarkMode } = useDarkMode();

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="32"
      height="32"
      viewBox="0 0 32 32"
      fill="currentColor"
      className="utilities-svg"
    >
      <rect width="32" height="32" rx="8" fill="currentColor" />
      <g clipPath="url(#clip0_739_15)">
        <path
          d="M18.691 17.0001C18.1726 17.0001 17.6542 17.0001 17.1357 17.0001C16.6173 17.0001 16.2007 17.0001 15.8859 17.0001C16.1081 16.0373 16.3303 15.0837 16.5525 14.1394C16.7747 13.2136 16.9968 12.2786 17.219 11.3343C17.4412 10.4085 17.6634 9.47347 17.8856 8.52917C18.2744 8.52917 18.6355 8.52917 18.9688 8.52917C19.3021 8.52917 19.6076 8.52917 19.8853 8.52917C20.163 8.52917 20.45 8.52917 20.7463 8.52917C21.024 8.52917 21.3388 8.52917 21.6906 8.52917C21.7646 8.52917 21.8295 8.53843 21.885 8.55694C21.922 8.59397 21.9405 8.631 21.9405 8.66804C21.9405 8.72358 21.9313 8.78839 21.9128 8.86245C21.598 9.69566 21.274 10.5566 20.9407 11.4454C20.6074 12.3342 20.2741 13.2322 19.9408 14.1394C19.5891 15.0652 19.265 15.954 18.9688 16.8057C18.9317 16.8983 18.8947 16.9538 18.8577 16.9723C18.8206 16.9909 18.7651 17.0001 18.691 17.0001ZM12.314 17.0001C11.7955 17.0001 11.2771 17.0001 10.7586 17.0001C10.2402 17.0001 9.8236 17.0001 9.50883 17.0001C9.73102 16.0373 9.95321 15.0837 10.1754 14.1394C10.3976 13.2136 10.6198 12.2786 10.842 11.3343C11.0642 10.4085 11.2863 9.47347 11.5085 8.52917C11.8974 8.52917 12.2584 8.52917 12.5917 8.52917C12.925 8.52917 13.2305 8.52917 13.5082 8.52917C13.786 8.52917 14.073 8.52917 14.3692 8.52917C14.6469 8.52917 14.9617 8.52917 15.3135 8.52917C15.3876 8.52917 15.4524 8.53843 15.5079 8.55694C15.545 8.59397 15.5635 8.631 15.5635 8.66804C15.5635 8.72358 15.5542 8.78839 15.5357 8.86245C15.2209 9.69566 14.8969 10.5566 14.5636 11.4454C14.2303 12.3342 13.8971 13.2322 13.5638 14.1394C13.212 15.0652 12.8879 15.954 12.5917 16.8057C12.5547 16.8983 12.5176 16.9538 12.4806 16.9723C12.4436 16.9909 12.388 17.0001 12.314 17.0001Z"
          fill={isDarkMode ? "#25282a" : "#f2f5fa"}
        />
      </g>
      <defs>
        <clipPath id="clip0_739_15">
          <rect
            x="1.96216"
            y="3.16992"
            width="27.9245"
            height="24.4528"
            rx="0.754717"
            fill={isDarkMode ? "#25282a" : "#f2f5fa"}
          />
        </clipPath>
      </defs>
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

通量

/* File: components/SVG/Flux.js */

import { useDarkMode } from "@context/darkModeContext";

export default function Flux() {
  const { isDarkMode } = useDarkMode();

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="32"
      height="32"
      viewBox="0 0 32 32"
      fill="currentColor"
      className="utilities-svg"
    >
      <g clipPath="url(#clip0_741_34)">
        <path
          d="M0 16C0 7.16344 7.16344 0 16 0V0C24.8366 0 32 7.16344 32 16V16C32 24.8366 24.8366 32 16 32V32C7.16344 32 0 24.8366 0 16V16Z"
          fill="currentColor"
        />
        <circle
          cx="22.6455"
          cy="17.5662"
          r="4.65609"
          fill="#9B9B9B"
          stroke={isDarkMode ? "black" : "white"}
          strokeWidth="1"
        />
        <path
          d="M30.9635 28.1791C30.9635 34.0935 17.838 31.8614 11.9236 31.8614C6.00918 31.8614 1.3739 28.8871 1.3739 22.9727C-3.07865 20.8706 4.19449 12.6984 10.3075 12.6984C20.0853 12.6984 30.2863 23.5295 30.9635 28.1791Z"
          fill="#454545"
        />
      </g>
      <defs>
        <clipPath id="clip0_741_34">
          <path
            d="M0 16C0 7.16344 7.16344 0 16 0V0C24.8366 0 32 7.16344 32 16V16C32 24.8366 24.8366 32 16 32V32C7.16344 32 0 24.8366 0 16V16Z"
            fill="white"
          />
        </clipPath>
      </defs>
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

微软待办

/* File: components/SVG/MicrosoftToDo.js */

export default function MicrosoftToDo() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="41"
      height="33"
      viewBox="0 0 41 33"
      fill="none"
      className="utilities-svg"
    >
      <rect
        width="22.3455"
        height="11.2534"
        rx="1.96723"
        transform="matrix(0.707135 0.707078 -0.707135 0.707078 8.14673 8.92383)"
        fill="#545454"
      />
      <g filter="url(#filter0_d_744_7)">
        <rect
          width="33.904"
          height="11.3527"
          rx="1.96723"
          transform="matrix(0.707135 -0.707078 0.707135 0.707078 8.07495 24.8127)"
          fill="currentColor"
        />
      </g>
      <defs>
        <filter
          id="filter0_d_744_7"
          x="8.103"
          y="0.474446"
          width="31.9466"
          height="31.9441"
          filterUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feFlood floodOpacity="0" result="BackgroundImageFix" />
          <feColorMatrix
            in="SourceAlpha"
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
            result="hardAlpha"
          />
          <feMorphology
            radius="0.786893"
            operator="erode"
            in="SourceAlpha"
            result="effect1_dropShadow_744_7"
          />
          <feOffset dy="-0.393446" />
          <feGaussianBlur stdDeviation="0.786893" />
          <feComposite in2="hardAlpha" operator="out" />
          <feColorMatrix
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
          />
          <feBlend
            mode="normal"
            in2="BackgroundImageFix"
            result="effect1_dropShadow_744_7"
          />
          <feBlend
            mode="normal"
            in="SourceGraphic"
            in2="effect1_dropShadow_744_7"
            result="shape"
          />
        </filter>
      </defs>
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

雨滴

/* File: components/SVG/RainDrop.js */

import React from "react";

export default function RainDrop() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="32"
      height="35"
      viewBox="0 0 32 35"
      fill="currentColor"
      className="utilities-svg"
    >
      <path
        d="M13.3433 13.6108C17.1634 17.0095 15.8846 24.5064 15.8846 27.8684C10.8766 27.8684 7.307 28.3185 4.09012 26.3952C0.217283 24.0796 0.108804 18.6472 2.664 15.1169C5.21919 11.5867 10.1254 10.7479 13.3433 13.6108Z"
        fill="#454545"
      />
      <path
        d="M18.5482 13.7231C14.7622 17.0326 15.9116 24.5606 15.8844 27.8684C20.8117 27.9137 24.3683 28.2484 27.5489 26.3851C31.3781 24.1418 31.5288 18.7979 29.0434 15.3014C26.5579 11.8049 21.7374 10.9354 18.5482 13.7231Z"
        fill="#9F9F9F"
      />
      <g filter="url(#filter0_d_745_19)">
        <path
          d="M25.8466 13.5213C25.8466 20.3855 18.6544 24.6615 15.884 27.8685C12.4211 23.5925 5.92139 20.2167 5.92139 13.5213C5.92139 7.71055 10.3818 3 15.884 3C21.3862 3 25.8466 7.71055 25.8466 13.5213Z"
          fill="currentColor"
        />
      </g>
      <defs>
        <filter
          id="filter0_d_745_19"
          x="0.921387"
          y="0"
          width="29.9253"
          height="34.8687"
          filterUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feFlood floodOpacity="0" result="BackgroundImageFix" />
          <feColorMatrix
            in="SourceAlpha"
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
            result="hardAlpha"
          />
          <feOffset dy="2" />
          <feGaussianBlur stdDeviation="2.5" />
          <feComposite in2="hardAlpha" operator="out" />
          <feColorMatrix
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
          />
          <feBlend
            mode="normal"
            in2="BackgroundImageFix"
            result="effect1_dropShadow_745_19"
          />
          <feBlend
            mode="normal"
            in="SourceGraphic"
            in2="effect1_dropShadow_745_19"
            result="shape"
          />
        </filter>
      </defs>
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

分享X

/* File: components/SVG/ShareX.js */

import { useDarkMode } from "@context/darkModeContext";

export default function ShareX() {
  const { isDarkMode } = useDarkMode();

  return (
    <svg
      width="32"
      height="32"
      viewBox="0 0 32 32"
      fill="currentColor"
      xmlns="http://www.w3.org/2000/svg"
      className="utilities-svg"
    >
      <g clipPath="url(#clip0_736_2)">
        <path
          d="M25.0435 10.8953C24.9357 6.27203 21.657 2.33881 17.0616 0.753423C15.6008 0.251362 14.0664 -0.00326983 12.5217 3.16972e-05C5.60626 3.16972e-05 5.88007e-08 4.98299 5.88007e-08 11.1305C-0.000141637 12.438 0.255807 13.7328 0.753391 14.9419C2.34226 10.3465 6.272 7.06786 10.8953 6.95655C10.9732 6.95655 11.0518 6.95655 11.1304 6.95655C13.8101 6.95655 16.2692 8.02229 18.1899 9.7976C18.6346 10.2098 19.0472 10.6554 19.424 11.1305C20.9489 13.0456 21.9576 15.4922 22.2031 18.1934C23.9777 16.2692 25.0435 13.8101 25.0435 11.1305C25.0435 11.0519 25.0435 10.9739 25.0435 10.8953Z"
          fill="currentColor"
        />
        <path
          d="M22.2024 17.842C21.9569 15.1408 20.9503 12.6942 19.4233 10.7791C19.0465 10.3053 18.6342 9.86083 18.1899 9.44969C16.2692 7.67438 13.8101 6.60864 11.1304 6.60864C11.0518 6.60864 10.9739 6.60864 10.8953 6.60864C6.272 6.71647 2.33878 9.99508 0.753391 14.5906C0.279533 13.4404 0.0240464 12.212 5.88007e-08 10.9683C5.88007e-08 11.0226 5.88007e-08 11.0761 5.88007e-08 11.1304C-0.000141637 12.4379 0.255807 13.7327 0.753391 14.9419C2.34226 10.3464 6.272 7.06777 10.8953 6.95647C10.9732 6.95647 11.0518 6.95647 11.1304 6.95647C13.8101 6.95647 16.2692 8.02221 18.1899 9.79751C18.6346 10.2097 19.0472 10.6553 19.424 11.1304C20.9489 13.0455 21.9576 15.4921 22.2031 18.1933C23.9777 16.2692 25.0435 13.81 25.0435 11.1304C25.0435 11.0761 25.0435 11.0226 25.0435 10.9683C24.9934 13.5763 23.9367 15.9659 22.2024 17.842Z"
          fill={isDarkMode ? "#25282a" : "#f2f5fa"}
        />
        <path
          d="M6.95655 21.1047C6.95655 21.0268 6.95655 20.9482 6.95655 20.8696C6.95655 18.1899 8.02229 15.7308 9.7976 13.8101C11.8379 11.6021 14.8153 10.1044 18.1899 9.79759C16.2692 8.02228 13.8101 6.95654 11.1305 6.95654C11.0519 6.95654 10.9739 6.95654 10.8953 6.95654C6.27203 7.06437 2.33881 10.343 0.753423 14.9385C0.251362 16.3993 -0.00326983 17.9336 3.16972e-05 19.4783C3.16972e-05 26.3938 4.98299 32 11.1305 32C12.438 32.0002 13.7328 31.7442 14.9419 31.2466C10.3465 29.6578 7.06786 25.728 6.95655 21.1047Z"
          fill="currentColor"
        />
        <path
          d="M6.95647 21.1047C6.95647 21.0268 6.95647 20.9482 6.95647 20.8696C6.95647 18.1899 8.02221 15.7308 9.79751 13.8101C11.8379 11.6021 14.8153 10.1044 18.1899 9.79759C16.2692 8.02228 13.81 6.95654 11.1304 6.95654C11.0761 6.95654 11.0226 6.95654 10.9683 6.95654C13.5763 7.00454 15.9659 8.06124 17.842 9.7948C14.4674 10.1016 11.49 11.5993 9.44969 13.8073C7.67438 15.7308 6.60864 18.1899 6.60864 20.8696C6.60864 20.9482 6.60864 21.0261 6.60864 21.1047C6.71647 25.728 9.99508 29.6612 14.5906 31.2466C13.4404 31.7205 12.212 31.976 10.9683 32C11.0226 32 11.0761 32 11.1304 32C12.4379 32.0002 13.7327 31.7442 14.9419 31.2466C10.3464 29.6578 7.06777 25.728 6.95647 21.1047Z"
          fill={isDarkMode ? "#25282a" : "#f2f5fa"}
        />
        <path
          d="M31.2466 17.0581C29.6578 21.6535 25.728 24.9321 21.1047 25.0434C21.0268 25.0434 20.9482 25.0434 20.8696 25.0434C18.1899 25.0434 15.7308 23.9777 13.8101 22.2024C11.6021 20.1621 10.1044 17.1847 9.79759 13.8101C8.02228 15.7308 6.95654 18.1899 6.95654 20.8695C6.95654 20.9481 6.95654 21.0261 6.95654 21.1047C7.06437 25.728 10.343 29.6612 14.9385 31.2466C16.3993 31.7486 17.9336 32.0033 19.4783 32C26.3938 32 32 27.017 32 20.8695C32.0002 19.562 31.7442 18.2672 31.2466 17.0581Z"
          fill="currentColor"
        />
        <path
          d="M9.79759 14.1579C10.1044 17.5325 11.6021 20.5099 13.8101 22.5502C15.7308 24.3255 18.1899 25.3913 20.8696 25.3913C20.9482 25.3913 21.0261 25.3913 21.1047 25.3913C25.728 25.2834 29.6612 22.0048 31.2466 17.4094C31.7205 18.5595 31.976 19.7879 32 21.0316C32 20.9774 32 20.9238 32 20.8695C32.0002 19.562 31.7442 18.2672 31.2466 17.0581C29.6578 21.6535 25.728 24.9321 21.1047 25.0434C21.0268 25.0434 20.9482 25.0434 20.8696 25.0434C18.1899 25.0434 15.7308 23.9777 13.8101 22.2024C11.6021 20.1621 10.1044 17.1847 9.79759 13.8101C8.02228 15.7308 6.95654 18.1899 6.95654 20.8695C6.95654 20.9238 6.95654 20.9774 6.95654 21.0316C7.00663 18.4236 8.06333 16.0341 9.79759 14.1579Z"
          fill={isDarkMode ? "#25282a" : "#f2f5fa"}
        />
        <path
          d="M20.8695 5.88007e-08C19.562 -0.000141637 18.2672 0.255807 17.0581 0.753391C21.6535 2.34226 24.9321 6.272 25.0434 10.8953C25.0434 10.9732 25.0434 11.0518 25.0434 11.1304C25.0434 13.8101 23.9777 16.2692 22.2024 18.1899C20.1621 20.3979 17.1847 21.8957 13.8101 22.2024C15.7308 23.9777 18.1899 25.0435 20.8695 25.0435C20.9481 25.0435 21.0261 25.0435 21.1047 25.0435C25.728 24.9357 29.6612 21.657 31.2466 17.0616C31.7486 15.6008 32.0033 14.0664 32 12.5217C32 5.60626 27.017 5.88007e-08 20.8695 5.88007e-08Z"
          fill="currentColor"
        />
        <path
          d="M14.1579 22.2024C17.5325 21.8957 20.5099 20.3979 22.5502 18.1899C24.3255 16.2692 25.3913 13.8101 25.3913 11.1304C25.3913 11.0518 25.3913 10.9739 25.3913 10.8953C25.2834 6.272 22.0048 2.33878 17.4094 0.753391C18.5595 0.279533 19.7879 0.0240464 21.0316 5.88007e-08C20.9774 5.88007e-08 20.9238 5.88007e-08 20.8695 5.88007e-08C19.562 -0.000141637 18.2672 0.255807 17.0581 0.753391C21.6535 2.34226 24.9321 6.272 25.0434 10.8953C25.0434 10.9732 25.0434 11.0518 25.0434 11.1304C25.0434 13.8101 23.9777 16.2692 22.2024 18.1899C20.1621 20.3979 17.1847 21.8957 13.8101 22.2024C15.7308 23.9777 18.1899 25.0435 20.8695 25.0435C20.9238 25.0435 20.9774 25.0435 21.0316 25.0435C18.4236 24.9934 16.0341 23.9367 14.1579 22.2024Z"
          fill={isDarkMode ? "#25282a" : "#f2f5fa"}
        />
      </g>
      <defs>
        <clipPath id="clip0_736_2">
          <rect width="32" height="32" fill="currentColor" />
        </clipPath>
      </defs>
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

合众国际社

/* File: components/SVG/UPI.js  */

import React from "react";

export default function UPI({ className }) {
  return (
    <svg
      className={className}
      width="80"
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 1024 466"
    >
      <path
        fill="#3d3d3c"
        d="M98.1 340.7h6.3l-5.9 24.5c-.9 3.6-.7 6.4.5 8.2 1.2 1.8 3.4 2.7 6.7 2.7 3.2 0 5.9-.9 8-2.7 2.1-1.8 3.5-4.6 4.4-8.2l5.9-24.5h6.4l-6 25.1c-1.3 5.4-3.6 9.5-7 12.2-3.3 2.7-7.7 4.1-13.1 4.1-5.4 0-9.1-1.3-11.1-4s-2.4-6.8-1.1-12.2l6-25.2zm31.4 40.3 10-41.9 19 24.6c.5.7 1 1.4 1.5 2.2.5.8 1 1.7 1.6 2.7l6.7-27.9h5.9l-10 41.8-19.4-25.1-1.5-2.1c-.5-.8-.9-1.5-1.2-2.4l-6.7 28h-5.9zm44.2 0 9.6-40.3h6.4l-9.6 40.3h-6.4zm15.5 0 9.6-40.3h21.9l-1.3 5.6h-15.5l-2.4 10H217l-1.4 5.7h-15.5l-4.5 18.9h-6.4zm29 0 9.6-40.3h6.4l-9.6 40.3h-6.4zm15.5 0 9.6-40.3h21.9l-1.3 5.6h-15.5l-2.4 10.1h15.5l-1.4 5.7h-15.5l-3.1 13H257l-1.4 5.9h-21.9zm29.3 0 9.6-40.3h8.6c5.6 0 9.5.3 11.6.9 2.1.6 3.9 1.5 5.3 2.9 1.8 1.8 3 4.1 3.5 6.8.5 2.8.3 6-.5 9.5-.9 3.6-2.2 6.7-4 9.5-1.8 2.8-4.1 5-6.8 6.8-2 1.4-4.2 2.3-6.6 2.9-2.3.6-5.8.9-10.4.9H263zm7.8-6h5.4c2.9 0 5.2-.2 6.8-.6 1.6-.4 3-1.1 4.3-2 1.8-1.3 3.3-2.9 4.5-4.9 1.2-1.9 2.1-4.2 2.7-6.8.6-2.6.8-4.8.5-6.7-.3-1.9-1-3.6-2.2-4.9-.9-1-2-1.6-3.5-2-1.5-.4-3.8-.6-7.1-.6h-4.6l-6.8 28.5zm59.7-12.1-4.3 18.1h-6l9.6-40.3h9.7c2.9 0 4.9.2 6.2.5 1.3.3 2.3.8 3.1 1.6 1 .9 1.7 2.2 2 3.8.3 1.6.2 3.3-.2 5.2-.5 1.9-1.2 3.7-2.3 5.3-1.1 1.6-2.4 2.9-3.8 3.8-1.2.7-2.5 1.3-3.9 1.6-1.4.3-3.6.5-6.4.5h-3.7zm1.7-5.4h1.6c3.5 0 6-.4 7.4-1.2 1.4-.8 2.3-2.2 2.8-4.2.5-2.1.2-3.7-.8-4.5-1.1-.9-3.3-1.3-6.6-1.3H335l-2.8 11.2zm40.1 23.5-2-10.4h-15.6l-7 10.4H341l29-41.9 9 41.9h-6.7zm-13.8-15.9h10.9l-1.8-9.2c-.1-.6-.2-1.3-.2-2-.1-.8-.1-1.6-.1-2.5-.4.9-.8 1.7-1.3 2.5-.4.8-.8 1.5-1.2 2.1l-6.3 9.1zm29.7 15.9 4.4-18.4-8-21.8h6.7l5 13.7c.1.4.2.8.4 1.4.2.6.3 1.2.5 1.8l1.2-1.8c.4-.6.8-1.1 1.2-1.6l11.7-13.5h6.4L399 362.5l-4.4 18.4h-6.4zm60.9-19.9c0-.3.1-1.2.3-2.6.1-1.2.2-2.1.3-2.9-.4.9-.8 1.8-1.3 2.8-.5.9-1.1 1.9-1.8 2.8l-15.4 21.5-5-21.9c-.2-.9-.4-1.8-.5-2.6-.1-.8-.2-1.7-.2-2.5-.2.8-.5 1.7-.8 2.7-.3.9-.7 1.9-1.2 2.9l-9 19.8h-5.9l19.3-42 5.5 25.4c.1.4.2 1.1.3 2 .1.9.3 2.1.5 3.5.7-1.2 1.6-2.6 2.8-4.4.3-.5.6-.8.7-1.1l17.4-25.4-.6 42h-5.9l.5-20zm10.6 19.9 9.6-40.3h21.9l-1.3 5.6h-15.5l-2.4 10.1h15.5l-1.4 5.7h-15.5l-3.1 13H483l-1.4 5.9h-21.9zm29.2 0 10-41.9 19 24.6c.5.7 1 1.4 1.5 2.2.5.8 1 1.7 1.6 2.7l6.7-27.9h5.9l-10 41.8-19.4-25.1-1.5-2.1c-.5-.8-.9-1.5-1.2-2.4l-6.7 28h-5.9zm65.1-34.8-8.3 34.7h-6.4l8.3-34.7h-10.4l1.3-5.6h27.2l-1.3 5.6H554zm6.7 26.7 5.7-2.4c.1 1.8.6 3.2 1.7 4.1 1.1.9 2.6 1.4 4.6 1.4 1.9 0 3.5-.5 4.9-1.6 1.4-1.1 2.3-2.5 2.7-4.3.6-2.4-.8-4.5-4.2-6.3-.5-.3-.8-.5-1.1-.6-3.8-2.2-6.2-4.1-7.2-5.9-1-1.8-1.2-3.9-.6-6.4.8-3.3 2.5-5.9 5.2-8 2.7-2 5.7-3.1 9.3-3.1 2.9 0 5.2.6 6.9 1.7 1.7 1.1 2.6 2.8 2.9 4.9l-5.6 2.6c-.5-1.3-1.1-2.2-1.9-2.8-.8-.6-1.8-.9-3-.9-1.7 0-3.2.5-4.4 1.4-1.2.9-2 2.1-2.4 3.7-.6 2.4 1.1 4.7 5 6.8.3.2.5.3.7.4 3.4 1.8 5.7 3.6 6.7 5.4 1 1.8 1.2 3.9.6 6.6-.9 3.8-2.8 6.8-5.7 9.1-2.9 2.2-6.3 3.4-10.3 3.4-3.3 0-5.9-.8-7.7-2.4-2-1.6-2.9-3.9-2.8-6.8zm47.1 8.1 9.6-40.3h6.4l-9.6 40.3h-6.4zm15.6 0 10-41.9 19 24.6c.5.7 1 1.4 1.5 2.2.5.8 1 1.7 1.6 2.7l6.7-27.9h5.9l-10 41.8-19.4-25.1-1.5-2.1c-.5-.8-.9-1.5-1.2-2.4l-6.7 28h-5.9zm65.1-34.8-8.3 34.7h-6.4l8.3-34.7h-10.4l1.3-5.6h27.2l-1.3 5.6h-10.4zm6.9 34.8 9.6-40.3h22l-1.3 5.6h-15.5l-2.4 10.1h15.5l-1.4 5.7h-15.5l-3.1 13h15.5l-1.4 5.9h-22zm39.5-18.1-4.3 18h-6l9.6-40.3h8.9c2.6 0 4.6.2 5.9.5 1.4.3 2.5.9 3.3 1.7 1 1 1.6 2.2 1.9 3.8.3 1.5.2 3.2-.2 5.1-.8 3.2-2.1 5.8-4.1 7.6-2 1.8-4.5 2.9-7.5 3.3l9.1 18.3h-7.2l-8.7-18h-.7zm1.6-5.1h1.2c3.4 0 5.7-.4 7-1.2 1.3-.8 2.2-2.2 2.7-4.3.5-2.2.3-3.8-.7-4.7-1-.9-3.1-1.4-6.3-1.4h-1.2l-2.7 11.6zm18.9 23.2 9.6-40.3h21.9l-1.3 5.6h-15.5l-2.4 10h15.5l-1.4 5.7h-15.5l-4.5 18.9h-6.4zm52.8 0-2-10.4h-15.6l-7 10.4h-6.7l29-41.9 9 41.9h-6.7zm-13.9-15.9h10.9l-1.8-9.2c-.1-.6-.2-1.3-.2-2-.1-.8-.1-1.6-.1-2.5-.4.9-.8 1.7-1.3 2.5-.4.8-.8 1.5-1.2 2.1l-6.3 9.1zm62.2-14.6c-1.4-1.6-3.1-2.8-4.9-3.5-1.8-.8-3.8-1.2-6.1-1.2-4.3 0-8.1 1.4-11.5 4.2-3.4 2.8-5.6 6.5-6.7 11-1 4.3-.6 7.9 1.4 10.8 1.9 2.8 4.9 4.2 8.9 4.2 2.3 0 4.6-.4 6.9-1.3 2.3-.8 4.6-2.1 7-3.8l-1.8 7.4c-2 1.3-4.1 2.2-6.3 2.8-2.2.6-4.4.9-6.8.9-3 0-5.7-.5-8-1.5s-4.2-2.5-5.7-4.5c-1.5-1.9-2.4-4.2-2.8-6.8-.4-2.6-.3-5.4.5-8.4.7-3 1.9-5.7 3.5-8.3 1.6-2.6 3.7-4.9 6.1-6.8 2.4-2 5-3.5 7.8-4.5s5.6-1.5 8.5-1.5c2.3 0 4.4.3 6.4 1 1.9.7 3.7 1.7 5.3 3.1l-1.7 6.7zm.6 30.5 9.6-40.3h21.9l-1.3 5.6h-15.5l-2.4 10.1h15.5l-1.4 5.7H868l-3.1 13h15.5L879 381h-21.9z"
      />
      <path
        fill="#70706e"
        d="M740.7 305.6h-43.9l61-220.3h43.9l-61 220.3zM717.9 92.2c-3-4.2-7.7-6.3-14.1-6.3H462.6l-11.9 43.2h219.4l-12.8 46.1H481.8v-.1h-43.9l-36.4 131.5h43.9l24.4-88.2h197.3c6.2 0 12-2.1 17.4-6.3 5.4-4.2 9-9.4 10.7-15.6l24.4-88.2c1.9-6.6 1.3-11.9-1.7-16.1zm-342 199.6c-2.4 8.7-10.4 14.8-19.4 14.8H130.2c-6.2 0-10.8-2.1-13.8-6.3-3-4.2-3.7-9.4-1.9-15.6l55.2-198.8h43.9l-49.3 177.6h175.6l49.3-177.6h43.9l-57.2 205.9z"
      />
      <path fill="#098041" d="M877.5 85.7 933 196.1 816.3 306.5z" />
      <path fill="#e97626" d="M838.5 85.7 894 196.1 777.2 306.5z" />
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

创建实用程序页面

添加所有图标后,让我们创建实用程序页面。

创建pages/utilities.js

import MetaData from "@components/MetaData";
import PageTop from "@components/PageTop";
import utilities from "@content/utilitiesData";
import Link from "next/link";
import AnimatedText from "@components/FramerMotion/AnimatedText";
import {
  FadeContainer,
  opacityVariant,
  popUp,
  popUpFromBottomForText,
} from "@content/FramerMotionVariants";
import { motion } from "framer-motion";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import pageMeta from "@content/meta";

export default function Utilities() {
  return (
    <>
      <MetaData
        title={pageMeta.utilities.title}
        description={utilities.description}
        previewImage={pageMeta.utilities.image}
        keywords={pageMeta.utilities.keywords}
      />

      <section className="pageTop font-inter">
        <PageTop pageTitle={utilities.title}>{utilities.description}</PageTop>

        <div className="flex flex-col gap-14">
          <UtilitySection utility={utilities.system} />
          <UtilitySection utility={utilities.tools} />
          <UtilitySection utility={utilities.software} />
        </div>

        <AnimatedText variants={opacityVariant} className="mt-12 -mb-10">
          Last Update on{" "}
          <span className="font-semibold">{utilities.lastUpdate}</span>
        </AnimatedText>
      </section>
    </>
  );
}

/* Each Utility Container */
function UtilitySection({ utility }) {
  return (
    <AnimatedDiv
      variants={FadeContainer}
      className="!w-full  selection:bg-blue-300 dark:selection:bg-blue-900 dark:selection:text-gray-400 dark:text-neutral-200 font-medium"
    >
      <motion.h2
        variants={popUpFromBottomForText}
        className="font-bold text-2xl sm:text-3xl font-barlow mb-4"
      >
        {utility.title}
      </motion.h2>

      <AnimatedDiv
        variants={FadeContainer}
        className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-3 mt-5"
      >
        {utility.data.map((item) => {
          return (
            <Link href={item.link} key={item.name} passHref>
              <motion.a
                variants={popUp}
                title={item.name + " - " + item.description}
                rel="noopener noreferrer"
                target="_blank"
                className="relative flex flex-col gap-3 items-center justify-center bg-white dark:bg-darkSecondary shadow dark:shadow-md p-8  border border-transparent hover:border-gray-400 dark:hover:border-neutral-600 rounded-md transition-all lg:hover:!scale-125 active:!scale-90 hover:z-10 hover:shadow-lg hover:origin-center text-gray-700 hover:text-black dark:text-gray-300/80 dark:hover:text-white"
              >
                <item.Icon className="utilities-svg" />

                <p className="absolute bottom-3 text-[10px] select-none">
                  {item.name}
                </p>
              </motion.a>
            </Link>
          );
        })}
      </AnimatedDiv>
    </AnimatedDiv>
  );
}
Enter fullscreen mode Exit fullscreen mode

在这里,我们将所有实用对象作为 props 传递,UtilitySection每个部分都会连同图标和数据一起渲染。这是一个可点击的链接,带有初始动画和悬停动画。

实用部分

博客页面

现在开始博客部分。一开始可能会有点混乱,但最终会明白的。我使用MDX作为内容管理器。你可能想知道为什么我使用 MDX。因为它的控制和自定义功能。它允许你随心所欲地自定义博客。你可以创建自己的自定义组件并将其添加到你的博客中。我们将创建几个自定义组件。此页面如下所示:

博客页面

博客页面包含顶部部分、实时搜索栏、书签图标和 RSS 提要,我们将在本文后面创建它们。

首先,我们需要创建一个名为MDXContent(稍后将创建)的类,我们将使用它来获取所有博客和内容。

posts只需在文件夹内创建一个带有扩展名的临时文件.mdx

创建temp.mdx

---
slug: temp
title: Tile of the blog
date: 2022-09-04
stringDate: September 04, 2022
published: true
keywords: temp, keyword, for , seo
excerpt: description of the blog
image: cover image url (required) https://imgur.com/zvS1Eyu.png
---

.............you content goes here.................
Enter fullscreen mode Exit fullscreen mode

上述代码包含frontMatter顶部内容,底部内容为主要内容。用户看不到前端内容,它仅用于获取博客的详细信息。文件名和slug必须完全相同。如果您不想在列表中显示博客,可以更改published为。false

文件目录看起来是这样的:

my-project/
├── components/
├── pages/
├── public/
└── posts/
    ├── temp.mdx
    ├── blog-2.mdx
    └── this-is-new-blog.mdx
Enter fullscreen mode Exit fullscreen mode

如果 slug 很长,请用连字符 (-) 分隔。现在,您知道目录中有一个博客文件。我们只需要读取该文件并将其渲染为 HTML。让我们MDXContentlib目录中创建类。

安装依赖项

我们需要安装一些依赖项才能使其工作,这里是您需要安装它们的列表:

获取 MDX 内容

创建lib/MDXContent.js

import path from "path";
import { readFileSync } from "fs";
import { sync } from "glob";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import readTime from "reading-time";
import rehypePrettyCode from "rehype-pretty-code";

export default class MDXContent {
  /* Takes folder as argument and find all the files inside that */
  constructor(folderName) {
    this.POST_PATH = path.join(process.cwd(), folderName);
  }

  /* Get all the slugs in the requested folder which has .mdx extension
   * It splits the path and gets only the slug part
   */
  getSlugs() {
    const paths = sync(`${this.POST_PATH}/*.mdx`);
    return paths.map((path) => {
      const parts = path.split("/");
      const fileName = parts[parts.length - 1];
      const [slug, _ext] = fileName.split(".");
      return slug;
    });
  }

  /* It's just to get the front matter not the full blog  */
  getFrontMatter(slug) {
    const postPath = path.join(this.POST_PATH, `${slug}.mdx`);
    const source = readFileSync(postPath);
    const { content, data } = matter(source);
    const readingTime = readTime(content);

    if (data.published) {
      return {
        slug,
        readingTime,
        excerpt: data.excerpt ?? "",
        title: data.title ?? slug,
        date: (data.date ?? new Date()).toString(),
        stringDate: data.stringDate ?? "",
        keywords: data.keywords ?? "",
        image: data.image ?? "https://imgur.com/aNqa9cE.png",
      };
    }
  }

  /* get the single post from slug (it's a full post with the content)  */
  async getPostFromSlug(slug, force = false) {
    const postPath = path.join(this.POST_PATH, `${slug}.mdx`);
    const source = readFileSync(postPath);
    const { content, data } = matter(source);
    if (!data.published && !force) return { post: null };

    // getting front matter
    const frontMatter = this.getFrontMatter(slug);

    /* code theme options */
    const prettyCodeOptions = {
      theme: "one-dark-pro",
      onVisitLine(node) {
        // Prevent lines from collapsing in `display: grid` mode, and
        // allow empty lines to be copy/pasted
        if (node.children.length === 0) {
          node.children = [{ type: "text", value: " " }];
        }
      },
      // Feel free to add classNames that suit your docs
      onVisitHighlightedLine(node) {
        node.properties.className.push("highlighted");
      },
      onVisitHighlightedWord(node) {
        node.properties.className = ["word"];
      },
    };

    /* serializing the markdown and passing the rehype plugins as MDX supports them */
    const mdxSource = await serialize(content, {
      mdxOptions: {
        rehypePlugins: [
          rehypeSlug,
          [rehypeAutolinkHeadings, { behaviour: "wrap" }],
          [rehypePrettyCode, prettyCodeOptions],
        ],
      },
    });
    return {
      post: {
        content: mdxSource,
        tableOfContents: this.getTableOfContents(content),
        meta: frontMatter,
      },
    };
  }

  /* Getting all posts 
    - First find all slugs
    - then map for each slug and get the front matter of that post
    - then filter the posts by date 
    - return as an array
  */
  getAllPosts() {
    const posts = this.getSlugs()
      .map((slug) => {
        return this.getFrontMatter(slug, false);
      })
      .filter((post) => post != null || post != undefined) // Filter post if it is not published
      .sort((a, b) => {
        if (new Date(a.date) > new Date(b.date)) return -1;
        if (new Date(a.date) < new Date(b.date)) return 1;
        return 0;
      });

    return posts;
  }

  /* Generate the table of contents for the blog
    - using a regular expression to get the headings of the blog only h2 to h6 then 
    - then generating levels a heading and removing # from the heading and returning as an array
  */
  getTableOfContents(markdown) {
    const regXHeader = /#{2,6}.+/g;
    const headingArray = markdown.match(regXHeader)
      ? markdown.match(regXHeader)
      : [];
    return headingArray.map((heading) => {
      return {
        level: heading.split("#").length - 1 - 2, // we starts from the 2nd heading that's why we subtract 2 and 1 is extra heading text
        heading: heading.replace(/#{2,6}/, "").trim(),
      };
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

这就是管理博客所需的全部内容。您可以使用 获取单篇文章getPostFromSlug,如果要获取所有文章,请使用getAllPosts

这些函数只能在服务器端使用,因为fs模块不允许在客户端使用此功能。因此我们将使用getStaticProps它。

现在我们有了一个博客,并且有一个MDXContent类来管理和获取博客数据。接下来,让我们创建一个博客页面或路由。

创建博客页面

在目录index.js创建一个名为的文件。pages/blogs

/* File: pages/blogs/index.js */

import { useState, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
  FadeContainer,
  opacityVariant,
  popUp,
  popUpFromBottomForText,
} from "@content/FramerMotionVariants";
import Link from "next/link";
import Blog from "@components/Blog"; // ======> not created yet
import Metadata from "@components/MetaData";
import { BiRss } from "react-icons/bi";
import { RiCloseCircleLine } from "react-icons/ri";
import { BsBookmark } from "react-icons/bs";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import PageTop from "@components/PageTop";
import MDXContent from "@lib/MDXContent"; // ===> importing MDX contents
import pageMeta from "@content/meta";

export default function Blogs({ blogs }) {
  const [searchValue, setSearchValue] = useState("");
  const [filteredBlogs, setFilteredBlogs] = useState([...blogs]);

  useEffect(() => {
    setFilteredBlogs(
      blogs.filter((post) =>
        post.title.toLowerCase().includes(searchValue.trim().toLowerCase())
      )
    );
  }, [searchValue, blogs]);

  return (
    <>
      <Metadata
        title={pageMeta.blogs.title}
        description={pageMeta.blogs.description}
        previewImage={pageMeta.blogs.image}
        keywords={pageMeta.blogs.keywords}
      />

      <section className="pageTop flex flex-col gap-2">
        <PageTop pageTitle="Blogs">
          I've been writing online since 2021, mostly about web development and
          tech careers. In total, I've written {blogs.length} articles till now.
        </PageTop>

        <AnimatedDiv variants={opacityVariant}>
          <div className="w-full lg:flex items-center text-sm leading-6 text-slate-400 rounded-md ring-1 ring-slate-900/10 px-2 py-1.5 shadow-sm hover:ring-slate-400 dark:bg-darkSecondary dark:highlight-white/5 dark:hover:bg-darkSecondary/90 mx-auto flex relative bg-white group">
            <svg
              width="24"
              height="24"
              fill="none"
              aria-hidden="true"
              className="mx-3 flex-none"
            >
              <path
                d="m19 19-3.5-3.5"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              ></path>
              <circle
                cx="11"
                cy="11"
                r="6"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              ></circle>
            </svg>
            <input
              className="px-3 text-slate-400  py-2 w-full  outline-none transition duration-200 bg-transparent font-medium font-inter"
              type="text"
              value={searchValue}
              onChange={(e) => setSearchValue(e.target.value)}
              placeholder="Search articles..."
            />

            <button
              type="button"
              onClick={() => setSearchValue("")}
              className="hidden group-hover:inline-flex"
            >
              <RiCloseCircleLine className="w-4 h-4 mr-3" />
            </button>
          </div>
        </AnimatedDiv>

        <section className="relative py-5  flex flex-col gap-2 min-h-[50vh]">
          <AnimatePresence>
            {filteredBlogs.length != 0 ? (
              <>
                <AnimatedDiv
                  variants={FadeContainer}
                  className="flex items-center justify-between"
                >
                  <motion.h3
                    variants={popUpFromBottomForText}
                    className="text-left font-bold text-2xl sm:text-3xl my-5"
                  >
                    All Posts ({filteredBlogs.length})
                  </motion.h3>

                  <div className="flex items-center gap-2">
                    <Link href="/blogs/bookmark" passHref>
                      <motion.a variants={popUp}>
                        <BsBookmark
                          title="Bookmark"
                          className="text-2xl cursor-pointer"
                        />
                      </motion.a>
                    </Link>

                    <Link href="/rss" passHref>
                      <motion.a variants={popUp}>
                        <BiRss
                          title="RSS"
                          className="text-3xl cursor-pointer"
                        />
                      </motion.a>
                    </Link>
                  </div>
                </AnimatedDiv>

                <AnimatedDiv
                  variants={FadeContainer}
                  className="grid grid-cols-1 gap-4 mx-auto md:ml-[20%] xl:ml-[24%]"
                >
                  {filteredBlogs.map((blog, index) => {
                    return <Blog key={index} blog={blog} />;
                  })}
                </AnimatedDiv>
              </>
            ) : (
              <div className="font-inter text-center font-medium dark:text-gray-400">
                No Result Found
              </div>
            )}
          </AnimatePresence>
        </section>
      </section>
    </>
  );
}

export async function getStaticProps() {
  const blogs = new MDXContent("posts").getAllPosts(); // getting all posts (only front matter) inside "posts" directory
  return {
    props: { blogs },
  };
}
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我获取博客信息new MDXContent("posts").getAllPosts(),然后将它们作为 props 传递给客户端。在客户端,我创建了一个 UI,然后渲染了Blog组件。此外,我还创建了一个博客实时搜索功能。但是,我们还没有在上面的代码中实现以下功能:

  • Blog成分
  • 书签支持
  • 博客的 RSS 提要支持

创建博客组件

/* File: components/Blog.js */

import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useState } from "react";
import OgImage from "./OgImage";

export default function Blog({ blog }) {
  return (
    <article className="card">
      <OgImage src={blog.image} alt={blog.title} />

      <div className="flex flex-col">
        <p className="text-gray-500 text-sm font-medium flex justify-between items-center">
          <span>{blog.stringDate}</span>
          <span>{blog.readingTime.text}</span>
        </p>
        <h1 className="mt-1 font-bold text-neutral-900 dark:text-neutral-200">
          {blog.title}
        </h1>
        <p className="mt-3 text-sm  text-gray-600 dark:text-gray-400 truncate-3">
          {blog.excerpt}
        </p>

        <div className="relative mt-4 flex items-center justify-between overflow-hidden">
          <Link passHref href={`/blogs/${blog.slug}`}>
            <a
              href={`/blogs/${blog.slug}`}
              className="px-5 md:px-6 py-2 md:py-2.5 rounded-lg bg-black hover:bg-neutral-900 text-white w-fit text-xs transition-all active:scale-95 font-medium select-none"
            >
              Read more
            </a>
          </Link>
        </div>
      </div>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

这是我们将要在主页上看到的博客卡片。现在,正如我之前提到的,它有一个使用本地存储将博客添加为书签的功能。如果你还没有听说过这个功能,我写了一篇博客来更详细地解释一下。

添加书签支持

我专门设计了一个钩子,用于存储和删除本地存储中的博客,并获取所有值。

/* File: hooks/useBookmarkBlogs.js  */

import { useState, useEffect } from "react";

/* It takes key and defaultValue if the data is not present */
const useBookmarkBlogs = (key, defaultValue) => {
  const [bookmarkedBlogs, setBookmarkedBlogs] = useState(() => {
    let currentValue;

    /* trying to get te data from local storage */
    try {
      currentValue = JSON.parse(localStorage.getItem(key) || defaultValue);
    } catch (error) {
      currentValue = defaultValue;
    }

    return currentValue;
  });

  /* trying to get te data from local storage */
  function getValue() {
    var data = JSON.parse(localStorage.getItem(key));
    if (data === null) {
      localStorage.setItem(key, JSON.stringify([]));
      return JSON.parse(localStorage.getItem(key));
    }
    return data;
  }

  /* add blog as bookmark */
  function addToBookmark(blogToBookmark) {
    var data = getValue();
    if (!data.includes(blogToBookmark)) {
      data.unshift(blogToBookmark); // add blog to the starting of the array
      setBookmarkedBlogs(data);
    }
  }

  function removeFromBookmark(blogToRemove) {
    var data = getValue();
    setBookmarkedBlogs(data.filter((blog) => blog.slug != blogToRemove));
  }

  /* it check if the bookmark is already present or not if yes then return true else false */
  function isAlreadyBookmarked(searchBySlug) {
    return bookmarkedBlogs
      .map((bookmarkedBlog) => bookmarkedBlog.slug === searchBySlug)
      .includes(true);
  }

  /* update the local storage as bookmarkedBlogs value change */
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(bookmarkedBlogs));
  }, [bookmarkedBlogs]);

  return {
    bookmarkedBlogs,
    addToBookmark,
    removeFromBookmark,
    isAlreadyBookmarked,
  };
};

export default useBookmarkBlogs;
Enter fullscreen mode Exit fullscreen mode

上述代码包含一个钩子函数,用于在localStorage书签中添加和删除博客。它返回四个值:

  • bookmarkedBlogs:所有已添加书签的博客
  • addToBookmark:将博客添加到书签的功能
  • removeFromBookmark:从书签中删除博客的功能
  • isAlreadyBookmarked:检查博客是否已添加书签的函数(以 slug 作为输入)

现在,我们刚刚创建了一个钩子,我们需要使用这个功能。让我们使用它:

/* File: components/Blog.js */

/* ..........imported modules......... */
import useLocalStorage from "@hooks/useBookmarkBlogs";

export default function Blog({ blog }) {
  const { isAlreadyBookmarked, addToBookmark, removeFromBookmark } =
    useLocalStorage("blogs", []);
  const [bookmarkModal, setBookmarkModal] = useState({ show: false, text: "" });

  useEffect(() => {
    if (bookmarkModal.text != "") {
      setTimeout(() => {
        setBookmarkModal({ show: false, text: "" });
      }, 2000);
    }
  }, [bookmarkModal]);

  function handleBookmark() {
    if (isAlreadyBookmarked(blog.slug)) {
      removeFromBookmark(blog.slug);
      setBookmarkModal({ show: true, text: "Removed from Bookmarks" });
    } else {
      addToBookmark(blog);
      setBookmarkModal({ show: true, text: "Added to Bookmarks" });
    }
  }

  return (
    <article className="card">
      <div className="relative mt-4 flex items-center justify-between overflow-hidden">
        {/* Just below the Read more button add the following code */}

        <button
          title="Save for Later"
          className="transition active:scale-75"
          onClick={handleBookmark}
        >
          {isAlreadyBookmarked(blog.slug) ? (
            <BsBookmarkFill className="w-6 h-6" />
          ) : (
            <BsBookmark className="w-6 h-6" />
          )}
        </button>

        <AnimatePresence>
          {bookmarkModal.show && (
            <motion.p
              initial="hidden"
              animate="visible"
              exit="hidden"
              variants={{
                hidden: { opacity: 0, right: -100 },
                visible: { right: 40, opacity: 1 },
              }}
              className="absolute px-2 py-1 text-[10px] bg-black text-white"
            >
              {bookmarkModal.text}
            </motion.p>
          )}
        </AnimatePresence>

        {/* End of the Code */}
      </div>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

以上操作足以将博客添加到书签中或从书签中移除。现在,让我们创建一个书签页面,用户可以在其中查看已添加书签的博客。

创建书签页面

  • bookmark.js在目录内创建一个名为的文件pages/blogs
  • 将以下代码添加到该文件
/* Filename: pages/blogs/bookmark.js */

import { AnimatePresence } from "framer-motion";
import { FadeContainer } from "@content/FramerMotionVariants";
import Blog from "@components/Blog";
import Metadata from "@components/MetaData";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import PageTop from "@components/PageTop";
import useBookmarkBlogs from "@hooks/useBookmarkBlogs";
import pageMeta from "@content/meta";

export default function Blogs() {
  const { bookmarkedBlogs } = useBookmarkBlogs("blogs", []);

  return (
    <>
      <Metadata
        title={pageMeta.bookmark.title}
        description={pageMeta.bookmark.description}
        previewImage={pageMeta.bookmark.image}
        keywords={pageMeta.bookmark.keywords}
      />

      <section className="pageTop flex flex-col gap-2 text-neutral-900 dark:text-neutral-200">
        <PageTop pageTitle="Bookmarks">
          Here you can find article bookmarked by you for Later use.
        </PageTop>

        <section className="relative py-5 px-2 flex flex-col gap-2 min-h-[50vh]">
          <AnimatePresence>
            {bookmarkedBlogs.length != 0 ? (
              <AnimatedDiv
                variants={FadeContainer}
                className="grid grid-cols-1 gap-4 mx-auto md:ml-[20%] xl:ml-[24%]"
              >
                {bookmarkedBlogs.map((blog, index) => {
                  return <Blog key={index} blog={blog} />;
                })}
              </AnimatedDiv>
            ) : (
              <div className="font-inter text-center font-medium dark:text-gray-400 mt-10">
                No Result Found
              </div>
            )}
          </AnimatePresence>
        </section>
      </section>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

之后,此页面看起来类似于以下内容,它将在localhost:3000/blogs/bookmark上可用

书签页

为博客添加 RSS 提要

首先,我们需要安装rss包来实现这一点。

pnpm i rss
Enter fullscreen mode Exit fullscreen mode

创造lib/generateRssFeed.js

/* Filename: lib/generateRssFeed.js */

import { writeFileSync } from "fs";
import MDXContent from "@lib/MDXContent";
import RSS from "rss";

export default async function getRSS() {
  const siteURL = "https://yourdomain.com";
  const allBlogs = new MDXContent("posts").getAllPosts();

  const feed = new RSS({
    title: "Your Name",
    description: "your description",
    site_url: siteURL,
    feed_url: `${siteURL}/feed.xml`,
    language: "en",
    pubDate: new Date(),
    copyright: `All rights reserved ${new Date().getFullYear()}, Your Name`,
  });

  allBlogs.map((post) => {
    feed.item({
      title: post.title,
      url: `${siteURL}/blogs/${post.slug}`,
      date: post.date,
      description: post.excerpt,
    });
  });

  writeFileSync("./public/feed.xml", feed.xml({ indent: true }));
}
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我们获取了所有博客帖子,然后将每个博客添加为 feed 项。这将在文件夹feed.xml中创建一个文件public。另外,我们还需要调用这个函数。

现在,我们只需要在数据更新时调用此函数来生成 RSS 源。为此,我们在 pages/index.js 中的 getStaticProps 中调用此函数,因为每当我们的网站构建和部署时,我们的 RSS 源都会被创建和更新。

/* Filename: pages/index.js */
import generateRSS from "../lib/generateRssFeed";

export async function getStaticProps() {
  // ........
  await generateRSS(); // calling to generate the feed
  // ........
}
Enter fullscreen mode Exit fullscreen mode

如果您想了解另一种添加 RSS 的方法,请考虑以下博客:

创建个人博客页面

这基本上是一个[slug]页面,它决定了用户访问个人博客时应该呈现的样子。我们已经使用 MDX 创建了内容。现在让我们实现[slug].js

首先安装next-mdx-remotehighlights.js

pnpm i next-mdx-remote highlight.js
Enter fullscreen mode Exit fullscreen mode

创造pages/blogs/[slug].js

/* Filename : pages/blogs/[slug].js */

import { useEffect } from "react";
import BlogLayout from "@layout/BlogLayout"; // ========> not created yet
import Metadata from "@components/MetaData";
import MDXComponents from "@components/MDXComponents"; // =====> will create in the end of the section
import PageNotFound from "pages/404"; // =======> not created yet
import MDXContent from "@lib/MDXContent";
import { MDXRemote } from "next-mdx-remote";
import "highlight.js/styles/atom-one-dark.css";

export default function Post({ post, error }) {
  if (error) return <PageNotFound />;

  return (
    <>
      <Metadata
        title={post.meta.title}
        description={post.meta.excerpt}
        previewImage={post.meta.image}
        keywords={post.meta.keywords}
      />

      <BlogLayout post={post}>
        <MDXRemote
          {...post.source}
          frontmatter={post.meta}
          components={MDXComponents}
        />
      </BlogLayout>
    </>
  );
}

/* Generating the page for every slug */
export async function getStaticProps({ params }) {
  const { slug } = params;
  const { post } = await new MDXContent("posts").getPostFromSlug(slug);

  if (post != null) {
    return {
      props: {
        error: false,
        post: {
          meta: post.meta,
          source: post.content,
          tableOfContents: post.tableOfContents,
        },
      },
    };
  } else {
    return {
      props: {
        error: true,
        post: null,
      },
    };
  }
}

/* Generating all possible paths for the slug */
export async function getStaticPaths() {
  const paths = new MDXContent("posts")
    .getSlugs()
    .map((slug) => ({ params: { slug } }));

  return {
    paths,
    fallback: false,
  };
}
Enter fullscreen mode Exit fullscreen mode

上面的代码只是获取所有 slug 并生成路径,然后这些路径(也就是 slug)会为每个 slug 生成单独的页面。我们现在需要创建两个东西PageNotFoundBlogLayout

创建 PageNotFound 组件

创建一个名为404.jsinsidepages目录的文件。

/* Filename: pages/404.js */

import React from "react";
import Link from "next/link";
import MetaData from "@components/MetaData";

export default function PageNotFound() {
  return (
    <>
      <MetaData title="404 -" description="You are lost in Space !!" />
      <section className="pageTop flex flex-col gap-5 md:pt-20">
        <h1 className="font-bold font-barlow text-3xl md:text-5xl uppercase dark:text-white">
          Stay calm and don't freak out!!
        </h1>
        <p className="font-inter text-gray-500 dark:text-gray-400/70">
          Looks like you've found the doorway to the great nothing. You didn't
          break the internet, but I can't find what you are looking for. Please
          visit my <b>Homepage</b> to get where you need to go.
        </p>

        <Link href="/" passHref>
          <div className="p-3 w-full xs:max-w-[200px] xs:mx-0 sm:p-3 font-bold mx-auto bg-gray-200 dark:bg-darkSecondary text-center rounded-md text-black dark:text-white select-none cursor-pointer">
            Take me there!
          </div>
        </Link>
      </section>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

创建 BlogLayout 组件

/* Filename : layout/BlogLayout.js */

import { AvatarImage } from "../utils/utils"; // =========> not created
import Image from "next/image";
import styles from "../styles/Blog.module.css"; // =========> not created
import ShareOnSocialMedia from "../components/ShareOnSocialMedia"; // =========> not created
import { FiPrinter } from "react-icons/fi";
import { TbEdit } from "react-icons/tb";
import Newsletter from "../components/Newsletter"; // =========> not created
import Link from "next/link";
import useWindowLocation from "@hooks/useWindowLocation";
import ScrollProgressBar from "@components/ScrollProgressBar"; // =========> not created
import { stringToSlug } from "@lib/toc"; // =========> not created
import { useState, useEffect } from "react";
import { lockScroll, removeScrollLock } from "@utils/functions"; // =========> not created
import useWindowSize from "@hooks/useWindowSize"; // =========> not created
import { FadeContainer, opacityVariant } from "@content/FramerMotionVariants";
import AnimatedHeading from "@components/FramerMotion/AnimatedHeading";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import useBookmarkBlogs from "@hooks/useBookmarkBlogs";
import { BsBookmark, BsBookmarkFill } from "react-icons/bs";
import useScrollPercentage from "@hooks/useScrollPercentage";

export default function BlogLayout({ post, children }) {
  const { currentURL } = useWindowLocation();
  const [isTOCActive, setIsTOCActive] = useState(false);
  const [alreadyBookmarked, setAlreadyBookmarked] = useState(false);

  const scrollPercentage = useScrollPercentage();

  const size = useWindowSize();

  const { isAlreadyBookmarked, addToBookmark, removeFromBookmark } =
    useBookmarkBlogs("blogs", []);

  useEffect(() => {
    // In Case user exists from mobile to desktop then remove the scroll lock and TOC active to false
    if (size.width > 768) {
      removeScrollLock();
      setIsTOCActive(false);
    }
  }, [size]);

  useEffect(() => {
    setAlreadyBookmarked(isAlreadyBookmarked(post.meta.slug));
  }, [isAlreadyBookmarked, post.meta.slug]);

  return (
    <section className="mt-[44px] md:mt-[60px]  relative !overflow-hidden">
      {/* TOC */}
      <div
        className={`fixed h-full ${
          isTOCActive
            ? "left-0 opacity-100 top-[44px] md:top-[60px]"
            : "-left-full opacity-0"
        } ${
          scrollPercentage > 95 ? "xl:-left-full" : "xl:left-0"
        } md:left-0 md:opacity-100 md:max-w-[35%] lg:max-w-[30%]  transition-all duration-500 flex flex-col gap-1 !pb-[100px] overflow-y-scroll p-10 md:p-14 h-screen fixed w-full font-barlow bg-darkWhite dark:bg-darkPrimary text-neutral-800 dark:text-gray-200 z-50 `}
      >
        <AnimatedHeading
          variants={opacityVariant}
          className="font-bold text-xl md:text-2xl -ml-[5px] md:-ml-[6px]"
        >
          Table of Contents
        </AnimatedHeading>

        <AnimatedDiv
          variants={FadeContainer}
          className="flex flex-col relative before:absolute before:left-0 before:h-full before:w-[1.5px] before:bg-neutral-500"
        >
          {post.tableOfContents.map((content) => {
            return (
              <Link
                key={content.heading}
                href={`#${stringToSlug(content.heading)}`}
                passHref
              >
                <a
                  className="relative overflow-hidden hover:bg-darkSecondary px-2 py-0.5 md:py-1 rounded-tr-md rounded-br-md md:truncate text-neutral-700 hover:text-white  dark:text-neutral-200 font-medium border-l-2 border-neutral-500 dark:hover:border-white"
                  style={{ marginLeft: `${content.level * 15}px` }}
                  key={content.heading}
                  onClick={() => {
                    if (size.width < 768) {
                      lockScroll();
                      setIsTOCActive(false);
                    }
                    setIsTOCActive(false);
                    removeScrollLock();
                  }}
                >
                  {content.heading}
                </a>
              </Link>
            );
          })}
        </AnimatedDiv>
      </div>

      <button
        onClick={() => {
          setIsTOCActive(!isTOCActive);
          lockScroll();
        }}
        className="md:hidden w-full py-2 font-medium bg-black dark:bg-white text-white dark:text-black fixed bottom-0 outline-none z-50"
      >
        Table of Contents
      </button>

      <section
        className="p-5 sm:pt-10 relative font-barlow prose dark:prose-invert md:ml-[35%] lg:ml-[30%]"
        style={{ maxWidth: "800px", opacity: isTOCActive && "0.3" }}
      >
        {/* Progress Bar */}
        <ScrollProgressBar />

        {/* Blog Front Matter & Author */}
        <h1 className="text-3xl font-bold tracking-tight text-black md:text-5xl dark:text-white">
          {post.meta.title}
        </h1>

        <div className="flex items-center !w-full text-gray-700 dark:text-gray-300">
          <div className="flex items-center gap-2 w-full">
            <div className="relative grid">
              <Image
                alt="Jatin Sharma"
                height={30}
                width={30}
                src={AvatarImage}
                layout="fixed"
                className="rounded-full"
              />
            </div>
            <div className="flex flex-col sm:flex-row sm:justify-between w-full">
              <p className="text-sm  flex items-center gap-2 font-medium !my-0">
                <span>Jatin Sharma</span>
                <span></span>
                <span>{post.meta.stringDate}</span>
              </p>

              <p className="text-sm  flex items-center gap-2 font-medium !my-0">
                <span>{post.meta.readingTime.text}</span>
                <span></span>
                <span>{post.meta.readingTime.words} words</span>
              </p>
            </div>
          </div>

          <div className="flex gap-2 ml-4">
            <Link
              href={`https://github.com/j471n/j471n.in/edit/main/posts/${post.meta.slug}.mdx`}
              passHref
            >
              <a
                title="Edit on Github"
                target="_blank"
                rel="noopener noreferrer"
                className="transition active:scale-75 select-none"
              >
                <TbEdit className="w-7 h-7 text-gray-700 dark:text-gray-300 " />
              </a>
            </Link>
            <button
              title="Save for Later"
              className="transition active:scale-75"
              onClick={() => {
                alreadyBookmarked
                  ? removeFromBookmark(post.meta.slug)
                  : addToBookmark(post.meta);
              }}
            >
              {alreadyBookmarked ? (
                <BsBookmarkFill className="w-6 h-6 " />
              ) : (
                <BsBookmark className="w-6 h-6 " />
              )}
            </button>
          </div>
        </div>

        {/* Main Blog Content */}
        <AnimatedDiv
          variants={opacityVariant}
          className={` ${styles.blog} blog-container prose-sm prose-stone`}
        >
          {children}
        </AnimatedDiv>

        {/* NewsLetter */}
        <Newsletter />

        {/* Share Blog on Social Media */}
        <div className="w-full flex flex-col items-center gap-4 my-10 print:hidden">
          <h3
            style={{ margin: "0" }}
            className="font-semibold text-xl dark:text-white"
          >
            Share on Social Media:
          </h3>
          <ShareOnSocialMedia
            className="flex gap-2 items-center flex-wrap w-fit"
            title={post.meta.title}
            url={currentURL}
            summary={post.meta.excerpt}
            cover_image={post.meta.image}
          >
            {/* Print the Blog */}
            <div className="bg-gray-700 text-white p-2 rounded-full cursor-pointer">
              <FiPrinter className="w-4 h-4" onClick={() => window.print()} />
            </div>
          </ShareOnSocialMedia>
        </div>
      </section>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

我知道这需要花很多时间去理解。但我会尝试逐一解释,这个BlogLayout组件有几个部分:

  • 目录(TOC)
  • 滚动进度条
  • 通讯
  • 在社交媒体上分享
  • 博客元
  • 主要博客内容

最后两个函数不言自明。还有很多东西我们还没有创建。它们将在本节结束时创建。在深入讨论主要部分之前,我们先创建一些上面代码中用到的基本函数。

创建AvatarImage

/* Filename: utils/utils.js */

/* ............... */
export const AvatarImage = "https://imgur.com/yaefpD9.png";
/* ............... */
Enter fullscreen mode Exit fullscreen mode

创建博客样式

/* Filename : styles/Blog.module.css */

.blog {
  @apply font-inter dark:!text-gray-400 w-full;
}

.blog img {
  @apply !mx-auto rounded-lg;
}
.blog a,
.blog > ul > li > a {
  @apply text-blue-500 underline;
}

.blog h1,
.blog h2,
.blog h3,
.blog h4,
.blog h5,
.blog h6 {
  @apply my-2;
}

.blog blockquote {
  @apply border-l-4 border-gray-500 pl-3;
}
.blog blockquote img {
  @apply !mx-0 !my-1;
}

.blog blockquote p {
  @apply text-gray-500;
}

.blog > div > table > thead > tr > th {
  @apply border border-black text-teal-500 text-center !p-2 !font-bold;
}
.blog > div > table > thead {
  @apply !text-gray-800;
}
.blog > div > table tbody > tr > td {
  @apply border border-black !p-2 table-cell;
}

.blog > figure {
  @apply rounded-md mx-auto overflow-hidden;
}

.blog > figure img {
  @apply mx-auto my-0 rounded-lg;
}
.blog > figure figcaption {
  @apply text-center text-sm font-medium italic text-gray-500 max-w-xl  mx-auto;
}

.blog code {
  @apply bg-gray-600 text-white;
}

.blog h1 code,
.blog h2 code,
.blog h3 code,
.blog h4 code,
.blog h5 code,
.blog h6 code {
  @apply bg-transparent font-semibold my-2 text-blue-500;
}

.blog div[data-rehype-pretty-code-title] {
  @apply bg-[#1f2937] rounded-tl-md rounded-tr-md border-b text-gray-500 dark:text-white w-full;
}

.blog > div[data-rehype-pretty-code-fragment] {
  @apply -mt-[6px] !-z-10;
}

/* Main code */
.blog > div[data-rehype-pretty-code-fragment] > pre,
.blog > pre {
  @apply my-0 border border-gray-50/50 rounded-bl-md rounded-br-md mb-4 sm:text-sm w-full !p-0;
}

.blog > div[data-rehype-pretty-code-fragment] code {
  @apply grid;
}

/* Main Heading of the Blog */
.blog h2 {
  @apply text-2xl;
}
Enter fullscreen mode Exit fullscreen mode

创造lockScrollremoveScrollLock

/* Filename: utils/functions.js */

export function lockScroll() {
  const root = document.getElementsByTagName("html")[0];
  root.classList.toggle("lock-scroll"); // class is define in the global.css
}
export function removeScrollLock() {
  const root = document.getElementsByTagName("html")[0];
  root.classList.remove("lock-scroll"); // class is define in the global.css
}
Enter fullscreen mode Exit fullscreen mode

创建useWindowSize钩子

/* Filename: hooks/useWindowSize.js */

import { useState, useEffect } from "react";
export default function useWindowSize() {
  // Initialize state with undefined width/height so server and client renders match
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });
  useEffect(() => {
    // Handler to call on window resize
    function handleResize() {
      // Set window width/height to state
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    // Add event listener
    window.addEventListener("resize", handleResize);
    // Call handler right away so state gets updated with initial window size
    handleResize();
    // Remove event listener on cleanup
    return () => window.removeEventListener("resize", handleResize);
  }, []); // Empty array ensures that effect is only run on mount
  return windowSize;
}
Enter fullscreen mode Exit fullscreen mode

创建目录

这将自动生成每个博客的目录。我们无需手动创建,以便用户轻松导航到所需的博客版块。

为了创建它,我们已经在MDXContent中有一个函数

/* Filename: lib/MDXContent.js */

/* Generate the table of contents for the blog
    - using regular expression to get the headings of the blog only h2 to h6 then
    - then generating levels an heading and removing ## from the heading and returning as array
  */
  getTableOfContents(markdown) {
    const regXHeader = /#{2,6}.+/g;
    const headingArray = markdown.match(regXHeader)
      ? markdown.match(regXHeader)
      : [];
    return headingArray.map((heading) => {
      return {
        level: heading.split("#").length - 1 - 2, // we starts from the 2nd heading that's why we subtract 2 and 1 is extra heading text
        heading: heading.replace(/#{2,6}/, "").trim(),
      };
    });
  }
Enter fullscreen mode Exit fullscreen mode

这会生成标题级别标题文本的数组,然后我们只需将该文本传递给stringToSlug函数即可。该函数将生成文章的 slug/URL。然后我们映射目录并在那里添加标题。

创建stringToSlug

/* Filename: lib/toc.js */

/* ---------Converts string to Slug-------- */
export function stringToSlug(str) {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s]+/g, "-")
    .replace(/^-+|-+$/g, "");
}
Enter fullscreen mode Exit fullscreen mode

它看起来是这样的:

目录

创建ScrollProgressBar组件

此进度条显示您已阅读文章的量。它根据您滚动的距离显示进度。

滚动

/* File:  components/ScrollProgressBar.js*/

import { useCallback, useEffect, useState } from "react";

export default function ScrollProgressBar() {
  const [scroll, setScroll] = useState(0);

  const progressBarHandler = useCallback(() => {
    const totalScroll = document.documentElement.scrollTop;
    const windowHeight =
      document.documentElement.scrollHeight -
      document.documentElement.clientHeight;
    const scroll = `${totalScroll / windowHeight}`;

    setScroll(scroll);
  }, []);

  useEffect(() => {
    window.addEventListener("scroll", progressBarHandler);
    return () => window.removeEventListener("scroll", progressBarHandler);
  }, [progressBarHandler]);
  return (
    <div
      className="!fixed left-0 w-full h-1 bg-black dark:bg-white origin-top-left  transform duration-300  top-[44px] sm:top-[63.5px] md:top-[60px]"
      style={{
        transform: `scale(${scroll},1)`,
        zIndex: 100,
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

新闻通讯组件

你需要安装dompurify。DOMPurify 是一款仅针对 DOM 的、速度超快、耐受性极强的 XSS 清理工具,适用于 HTML 和 SVG。此步骤可选

我接受用户的电子邮件作为输入,然后向/api/subscribe路由发出 POST 请求。我们稍后会创建它。

/* Filename: components/Newsletter.js */

import { useState } from "react";
import { AiOutlineSend } from "react-icons/ai";
import { sanitize } from "dompurify";
import { ToastContainer, toast } from "react-toastify";

export default function Newsletter() {
  const [email, setEmail] = useState("");
  const [loading, setLoading] = useState(false);

  const toastOptions = {
    theme: "colored",
    className: "w-full sm:w-96 font-inter",
    position: "top-center",
    autoClose: 3000,
    hideProgressBar: false,
    closeOnClick: true,
    pauseOnHover: false,
    draggable: true,
    progress: undefined,
  };

  async function subscribeNewsLetter(e) {
    e.preventDefault();
    setLoading(true);

    // validating the email if it is disposable or not
    const { disposable } = await fetch(
      `https://open.kickbox.com/v1/disposable/${email.split("@")[1]}`,
      { method: "GET" }
    ).then((res) => res.json());

    if (disposable) {
      setLoading(false);
      return toast.error(
        "You almost had me, now seriously enter the valid email",
        toastOptions
      );
    }

    // Adding the subscriber to the database
    fetch("/api/subscribe", {
      method: "POST",
      body: JSON.stringify({
        email: sanitize(email), // just to make sure everything is correct
      }),
    })
      .then((res) => res.json())
      .then((res) => {
        if (res.error) {
          toast.error(res.msg, toastOptions);
        } else {
          toast.success(res.msg, toastOptions);
        }
        setLoading(false);
      });
  }

  return (
    <>
      <div className="w-full p-4 font-barlow rounded-lg border-2 bg-white dark:bg-darkSecondary/20 dark:border-neutral-600 flex flex-col gap-4 mt-10 mb-5 print:hidden">
        <h2 className="text-2xl font-bold dark:text-white !my-0">
          Jatin's Newsletter
        </h2>
        <p className="text-gray-500 !my-0">
          Get notified in your inbox whenever I write a new blog post.
        </p>

        <form className="relative w-full" onSubmit={subscribeNewsLetter}>
          <input
            className="px-4 py-3 rounded-lg text-lg bg-gray-200 dark:bg-darkSecondary outline-none border-0 w-full placeholder:text-gray-700 dark:placeholder:text-gray-400 dark:text-gray-300"
            type="email"
            name="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="example@email.com"
            required
          />

          <button
            className="absolute right-0 top-0 bottom-0 px-4 m-[3px] bg-white dark:text-white dark:bg-neutral-600/40   rounded-md font-medium font-inter transform duration-200 active:scale-90 select-none"
            type="submit"
          >
            <div className="relative flex items-center gap-2 !my-0">
              {loading ? (
                <svg
                  className="animate-spin h-5 w-5"
                  xmlns="http://www.w3.org/2000/svg"
                  fill="none"
                  viewBox="0 0 24 24"
                >
                  <circle
                    className="opacity-25"
                    cx="12"
                    cy="12"
                    r="10"
                    stroke="currentColor"
                    strokeWidth="4"
                  ></circle>
                  <path
                    className="opacity-75"
                    fill="currentColor"
                    d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                  ></path>
                </svg>
              ) : (
                <>
                  <AiOutlineSend className="text-xl" />
                  <p className="hidden sm:inline-flex !my-0">Subscribe</p>
                </>
              )}
            </div>
          </button>
        </form>
      </div>
      <ToastContainer
        className="w-full mx-auto"
        theme={"colored"}
        style={{ zIndex: 1000 }}
        position="top-center"
        autoClose={3000}
        hideProgressBar={false}
        newestOnTop={false}
        closeOnClick
        rtl={false}
        pauseOnFocusLoss
        draggable
        pauseOnHover={false}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

为新闻通讯创建 API 路由

我正在使用Twitter的Revue新闻通讯。您可以在Revue API 文档中了解有关如何获取 API 密钥和设置的更多信息。

/* Filename: pages/api/subscribe.js */

export default async function handler(req, res) {
  const body = req.body;
  const { email } = JSON.parse(body);

  if (!email) {
    return res.status(400).json({
      error: true,
      msg: "Forgot to add your email?",
    });
  }

  const result = await fetch("https://www.getrevue.co/api/v2/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Token ${process.env.REVUE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });
  const data = await result.json();
  if (!result.ok) {
    return res.status(500).json({ error: true, msg: data.error.email[0] });
  }

  return res.status(201).json({
    error: false,
    msg: "Voilà, you are on my list. Please check your inbox",
  });
}
Enter fullscreen mode Exit fullscreen mode

您需要添加REVUE_API_KEY您的.env.local

在社交媒体上分享博客

通过这个,用户可以在社交媒体上分享博客。你需要安装react-share

让我们创建一个名为ShareOnSocialMediainsidecomponents目录的组件。

/* Filename: components/ShareOnSocialMedia.js */

import { BsThreeDots } from "react-icons/bs";

import {
  FacebookShareButton,
  LinkedinShareButton,
  TwitterShareButton,
  WhatsappShareButton,
} from "react-share";

import useShare from "../hooks/useShare"; //======> not created yet
import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { FiCopy, FiLinkedin } from "react-icons/fi";
import { FaWhatsapp } from "react-icons/fa";
import { GrFacebookOption, GrTwitter } from "react-icons/gr";

export default function ShareOnSocialMedia({
  className,
  title,
  url,
  summary,
  cover_image,
  children,
}) {
  const { isShareSupported } = useShare();

  async function handleShare() {
    if (window.navigator.share) {
      window.navigator
        .share({
          title: title,
          text: summary,
          url: url,
          // files: [file ],
        })
        .then(() => {
          console.log("Thanks for sharing!");
        })
        .catch(console.error);
    }
  }

  // copy to clipboard functions
  function copyTextToClipboard(text) {
    if (!navigator.clipboard) {
      toast.error(
        "Sorry, Your device doesn't supports This feature. Please Change your device ✌️ "
      );
      return;
    }
    navigator.clipboard.writeText(text).then(
      function () {
        toast.success("Link Copied Successfully 🙌");
      },
      function (err) {
        console.error(err);
        toast.success(
          "Something Went wrong I don't know what 🤔 use other methods"
        );
      }
    );
  }

  return (
    <>
      <div className={`${className} transform sm:scale-150 my-5`}>
        {/* Facebook */}
        <FacebookShareButton quote={title} url={url}>
          <div className="bg-gray-700 text-white p-2 rounded-full">
            <GrFacebookOption className="w-4 h-4" />
          </div>
        </FacebookShareButton>

        {/* Twitter */}
        <TwitterShareButton title={title} url={url} related={["@j471n_"]}>
          <div className="bg-gray-700 text-white p-2 rounded-full">
            <GrTwitter className="w-4 h-4" />
          </div>
        </TwitterShareButton>

        {/* Linkedin */}
        <LinkedinShareButton
          title={title}
          summary={summary}
          url={url}
          source={url}
        >
          <div className="bg-gray-700 text-white p-2 rounded-full">
            <FiLinkedin className="w-4 h-4 " />
          </div>
        </LinkedinShareButton>

        {/* Whatsapp */}
        <WhatsappShareButton title={title} url={url}>
          <div className="bg-gray-700 text-white p-1.5 rounded-full">
            <FaWhatsapp className="w-5 h-5 " />
          </div>
        </WhatsappShareButton>

        {/* Copy URL */}
        <div className="bg-gray-700 text-white p-2 rounded-full cursor-pointer">
          <FiCopy
            className="w-4 h-4 "
            onClick={() => copyTextToClipboard(url)}
          />
        </div>

        {/* children of components */}
        {children}

        {/* If share supported then show this native share option */}
        {isShareSupported && (
          <div
            className="bg-gray-700 text-white p-2 rounded-full cursor-pointer"
            onClick={handleShare}
          >
            <BsThreeDots className="w-4 h-4" />
          </div>
        )}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

创建useShare钩子

此钩子仅检查您的浏览器是否支持本机共享并返回布尔状态。

/* Filename: hook/useShare.js */

import { useEffect, useState } from "react";
function useShare() {
  // state for share supports
  const [isShareSupported, setIsShareSupported] = useState(null);

  // checking if that exist or not
  useEffect(() => {
    setIsShareSupported(() => (window.navigator.share ? true : false));
  }, []);

  return { isShareSupported };
}

export default useShare;
Enter fullscreen mode Exit fullscreen mode

MDX组件

这些是博客中使用的自定义组件。通过它们,我们可以做任何事情。它们有以下几个组件:

CodeSandbox

/* Filename: components/MDXComponents/CodeSandbox.js */

export default function Codepen({ id, hideNavigation = true }) {
  return (
    <div className="my-3 print:hidden">
      <h3>Code Sandbox</h3>
      <iframe
        className="w-full h-[500px] border-0 rounded overflow-hidden"
        src={`https://codesandbox.io/embed/${id}?fontsize=14&theme=dark&hidenavigation=${
          hideNavigation ? 1 : 0
        }`}
        allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
        sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
      ></iframe>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

代码标题

/* Filename: components/MDXComponents/CodeTitle.js */

import { BsFileEarmarkCodeFill } from "react-icons/bs";
import { SiCss3, SiPython, SiGnubash, SiHtml5, SiReact } from "react-icons/si";
import { VscJson } from "react-icons/vsc";
import { IoLogoJavascript } from "react-icons/io5";
export default function CodeTitle({ title, lang }) {
  let Icon;
  switch (lang) {
    case "html":
      Icon = SiHtml5;
      break;
    case "css":
      Icon = SiCss3;
      break;
    case "js":
      Icon = IoLogoJavascript;
      break;
    case "bash":
      Icon = SiGnubash;
      break;
    case "py":
      Icon = SiPython;
      break;
    case "json":
      Icon = VscJson;
      break;
    case "jsx":
      Icon = SiReact;
      break;
    default:
      Icon = BsFileEarmarkCodeFill;
      break;
  }
  return (
    <div className="relative !z-10">
      <div className="bg-[#1f2937] rounded-tl-md rounded-tr-md p-3 text-gray-200 flex items-center justify-between font-mono !mt-4 overflow-x-scroll xs:overflow-auto border-b  border-b-gray-50/50 ">
        <div className="flex items-center gap-2">
          <Icon className="flex items-center w-4 h-4" />
          <p className="!my-0 font-[500] text-sm">{title}</p>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Codepen

/* Filename: components/MDXComponents/Codepen.js */

export default function Codepen({ id }) {
  return (
    <div className="my-3 print:hidden">
      <iframe
        height="600"
        style={{ marginTop: "10px" }}
        className="w-full"
        scrolling="no"
        src={`https://codepen.io/j471n/embed/${id}`}
        frameBorder="no"
        loading="lazy"
        allowFullScreen={true}
      ></iframe>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

危险

/* Filename: components/MDXComponents/Danger.js */

export default function Danger({ title, text }) {
  return (
    <div className="border-l-4  border-red-700 dark:border-red-500 bg-red-100 dark:bg-red-700/30 p-6 my-4 w-full">
      <div className="text-2xl font-medium leading-tight mb-2 flex items-center gap-2 text-red-700 dark:text-red-500">
        <svg
          aria-hidden="true"
          focusable="false"
          data-icon="times-circle"
          className="w-4 h-4 mr-2 fill-current"
          role="img"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 512 512"
        >
          <path
            fill="currentColor"
            d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"
          ></path>
        </svg>
        {title || "Danger"}
      </div>
      <p className="mt-4 text-red-700/80 dark:text-red-400/50">{text}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

图题

/* Filename: components/MDXComponents/Figcaption.js */

export default function figcaption({ src, caption, alt }) {
  if (caption !== undefined) {
    return (
      <figure>
        <img src={src} alt={alt} />
        <figcaption>{caption}</figcaption>
      </figure>
    );
  } else {
    return <img src={src} alt={alt} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

下一个和上一个按钮

/* Filename: components/MDXComponents/NextAndPreviousButton.js */

import Link from "next/link";
import { IoArrowForwardSharp } from "react-icons/io5";
export default function NextAndPreviousButton({
  prevHref,
  prevTitle,
  nextHref,
  nextTitle,
}) {
  return (
    <div className="flex flex-col gap-2 lg:flex-row ">
      {prevHref && prevTitle && (
        <BlogPageButton href={prevHref} title={prevTitle} type="previous" />
      )}
      {nextHref && nextTitle && (
        <BlogPageButton href={nextHref} title={nextTitle} type="next" />
      )}
    </div>
  );
}

function BlogPageButton({ href, title, type }) {
  return (
    <Link title={title} href={href} passHref>
      <a
        className={`flex ${
          type === "previous" && "flex-row-reverse"
        } justify-between bg-neutral-800 hover:bg-black !no-underline p-3 rounded-md active:scale-95 transition w-full shadow dark:hover:ring-1 dark:ring-white`}
      >
        <div
          className={`flex flex-col gap-1 ${
            type === "previous" && "text-right"
          }`}
        >
          <p className="text-gray-300  !my-0 capitalize text-sm sm:font-light">
            {type} Article
          </p>
          <p className="text-white font-bold sm:font-medium !my-0 text-base">
            {title}
          </p>
        </div>

        <IoArrowForwardSharp
          className={`bg-white text-black p-2 rounded-full w-8 h-8 self-center ${
            type === "previous" && "rotate-180"
          }`}
        />
      </a>
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

/* Filename: components/MDXComponents/Pre.js */

import { useState, useRef } from "react";

const Pre = (props) => {
  const textInput = useRef(null);
  const [hovered, setHovered] = useState(false);
  const [copied, setCopied] = useState(false);

  const onEnter = () => {
    setHovered(true);
  };
  const onExit = () => {
    setHovered(false);
    setCopied(false);
  };
  const onCopy = () => {
    setCopied(true);
    navigator.clipboard.writeText(textInput.current.textContent);
    setTimeout(() => {
      setCopied(false);
    }, 2000);
  };

  return (
    <div
      className="relative"
      ref={textInput}
      onMouseEnter={onEnter}
      onMouseLeave={onExit}
    >
      {hovered && (
        <button
          aria-label="Copy code"
          type="button"
          className={`!z-40 absolute right-2 top-3 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
            copied
              ? "border-green-400 focus:border-green-400 focus:outline-none"
              : "border-gray-400"
          }`}
          onClick={onCopy}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            stroke="currentColor"
            fill="none"
            className={copied ? "text-green-400" : "text-gray-400"}
          >
            {copied ? (
              <>
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
                />
              </>
            ) : (
              <>
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
                />
              </>
            )}
          </svg>
        </button>
      )}

      <pre className="!my-0 !rounded-md  !w-full !p-0 !py-3">
        {props.children}
      </pre>
    </div>
  );
};

export default Pre;
Enter fullscreen mode Exit fullscreen mode

/* Filename: components/MDXComponents/Step.js */

export default function Step({ id, children }) {
  return (
    <div className="flex items-center gap-3 flex-">
      <div className="flex items-center justify-center   bg-gray-300  font-bold dark:border-gray-800 rounded-full p-5 w-10 h-10 g-gray-300 ring dark:bg-darkSecondary text-black dark:text-white ">
        {id}
      </div>
      <div className="text-lg tracking-tight font-semibold text-black dark:text-white flex-grow-0 w-fit">
        {children}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

提示

/* Filename: components/MDXComponents/Tip.js */

export default function Tip({ id, children }) {
  return (
    <div className="relative w-full px-5 pt-4 flex gap-2 items-center bg-yellow-100 dark:bg-neutral-800 rounded-md my-5">
      <div className="font-barlow font-bold uppercase px-4 py-0 rounded-tl-md rounded-br-md absolute top-0 left-0 bg-yellow-400 dark:bg-yellow-500 text-black">
        Tip {id && `#${id}`}
      </div>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

警告

/* Filename: components/MDXComponents/Warning.js */

export default function Warning({ text, title, children }) {
  return (
    <div className="border-l-4 border-yellow-700 dark:border-yellow-500 bg-yellow-100 dark:bg-yellow-900 p-6 my-4 w-full">
      <div className="text-2xl font-medium leading-tight mb-2 flex items-center gap-2 text-yellow-700 dark:text-yellow-500">
        <svg
          aria-hidden="true"
          dataicon="exclamation-triangle"
          className="w-6 h-6 mr-2 fill-current"
          role="img"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 576 512"
        >
          <path
            fill="currentColor"
            d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
          ></path>
        </svg>
        {title || "Warning"}
      </div>
      <p className="mt-4 text-yellow-700/80 dark:text-yellow-400/50">
        {text || children}
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

YouTube

/* Filename: components/MDXComponents/YouTube.js */

export default function YouTube({ id }) {
  return (
    <div className="max-w-full overflow-hidden relative pb-[56.25%] h-0 ">
      <iframe
        className="absolute top-0 left-0 h-full w-full"
        src={`https://www.youtube.com/embed/${id}`}
        title="YouTube video player"
        frameBorder={0}
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowFullScreen
      ></iframe>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

导出 MDX 组件

/* Filename: components/MDXComponents/index.js */

import Codepen from "./Codepen";
import Figcaption from "./Figcaption";
import Warning from "./Warning";
import Danger from "./Danger";
import CodeTitle from "./CodeTitle";
import Tip from "./Tip";
import Pre from "./Pre";
import Step from "./Step";
import CodeSandbox from "./CodeSandbox";
import NextAndPreviousButton from "./NextAndPreviousButton";
import YouTube from "./YouTube";

const MDXComponents = {
  Codepen,
  Figcaption,
  Warning,
  Danger,
  CodeTitle,
  Tip,
  Step,
  CodeSandbox,
  NextAndPreviousButton,
  YouTube,
  pre: Pre,
};

export default MDXComponents;
Enter fullscreen mode Exit fullscreen mode

关于我页面

我创建了这个关于我自己的页面。页面末尾有一个“支持我”版块,稍后会创建。

/* Filename: pages/about.js  */

import MDXComponents from "@components/";
import MetaData from "@components/MetaData";
import PageTop from "@components/PageTop";
import Support from "@components/Support"; //===> not created yet
import MDXContent from "@lib/MDXContent";
import { MDXRemote } from "next-mdx-remote";
import styles from "@styles/Blog.module.css";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";
import { opacityVariant } from "@content/FramerMotionVariants";
import pageMeta from "@content/meta";

export default function About({ about }) {
  return (
    <>
      <MetaData
        title={pageMeta.about.title}
        description={pageMeta.about.description}
        previewImage={pageMeta.about.image}
        keywords={pageMeta.about.keywords}
      />

      <section className="pageTop">
        <PageTop pageTitle="About me"></PageTop>
        <AnimatedDiv
          variants={opacityVariant}
          className={` ${styles.blog} blog-container prose-sm  3xl:prose-lg`}
        >
          <MDXRemote
            {...about.content}
            frontmatter={about.meta}
            components={MDXComponents}
          />
        </AnimatedDiv>
        <Support />
      </section>
    </>
  );
}

export async function getStaticProps() {
  const { post: about } = await new MDXContent("static_pages").getPostFromSlug(
    "about"
  );

  return {
    props: {
      about,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

我已经创建了一个关于我的 Markdown 页面,然后我们将该页面提取到里面getStaticProps。创建about.mdx内部static_pages目录。

---
slug: about
title: About me
date: 2022-06-25
stringDate: June 25, 2022
published: true
excerpt: ""
image: https://imgur.com/C6GBjJt.png
---

...............................
...............................
...............................
......Paste about you here.....
...............................
...............................
...............................
Enter fullscreen mode Exit fullscreen mode

创建支持组件

安装react-qr-code来生成 UPI 的二维码。(此部分可选)

在本节中,您有三个选项可以支持我

  • 给我买杯咖啡
  • PayPal
  • 合众国际社

当您选择 UPI 作为付款方式时,系统会提示您输入金额(货币:INR)。然后,系统会生成该金额的二维码,您可以使用任何支持 UPI 的应用程序(例如 GooglePay、Amazon Pay UPI 等)扫描该二维码。演示如下:

UPI 组件演示

/* Filename: component/Support.js */

import UPI from "@components/SVG/UPI";
import {
  FadeContainer,
  fromTopVariant,
  popUp,
} from "@content/FramerMotionVariants";
import support from "@content/support"; //======> not created yet
import Link from "next/link";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useDarkMode } from "@context/darkModeContext";
import QRCode from "react-qr-code";
import { BiRupee } from "react-icons/bi";
import { IoMdArrowRoundBack } from "react-icons/io";
import { FiInfo } from "react-icons/fi";
import { lockScroll, removeScrollLock } from "@utils/functions";
import AnimatedDiv from "@components/FramerMotion/AnimatedDiv";

export default function Support() {
  const [showUPIForm, setShowUPIForm] = useState(false);

  return (
    <section>
      <h3 className="my-5 font-bold text-2xl">Support me 💪</h3>

      <AnimatedDiv
        variants={FadeContainer}
        className="grid gap-5 sm:grid-cols-3"
      >
        {support.map((paymentMethod) => {
          return (
            <Link key={paymentMethod.name} href={paymentMethod.url} passHref>
              <motion.a
                variants={popUp}
                target="_blank"
                rel="noopener noreferrer"
                className="bg-white text-darkSecondary dark:bg-darkSecondary dark:text-gray-300 grid place-items-center p-5 group rounded-xl hover:ring-1 shadow ring-gray-500 duration-200 active:ring"
              >
                <div className="flex flex-col items-center gap-5 select-none">
                  <paymentMethod.Icon className="text-3xl duration-150 group-hover:lg:scale-150 " />

                  <p className="font-semibold text-sm">{paymentMethod.name}</p>
                </div>
              </motion.a>
            </Link>
          );
        })}
        <motion.button
          variants={popUp}
          onClick={() => {
            setShowUPIForm(!showUPIForm);
            lockScroll();
          }}
          className="bg-white text-darkSecondary dark:bg-darkSecondary dark:text-gray-300 grid place-items-center p-5 group rounded-xl hover:ring-1 shadow ring-gray-500 duration-200 active:ring"
        >
          <div className="flex flex-col items-center gap-5 select-none">
            <UPI className="text-3xl duration-150 group-hover:lg:scale-150 " />
            <p className="font-semibold text-sm">UPI</p>
          </div>
        </motion.button>
      </AnimatedDiv>
      <AnimatePresence>
        {showUPIForm && (
          <UPIPaymentForm
            close={() => {
              setShowUPIForm(false);
              removeScrollLock();
            }}
          />
        )}
      </AnimatePresence>
    </section>
  );
}

/* It's the UPI form when you choose UPI  */
function UPIPaymentForm({ close }) {
  const [amount, setAmount] = useState("");
  const [qrValue, setQrValue] = useState("");

  const { isDarkMode } = useDarkMode();

  const generatePaymentQR = (e) => {
    e.preventDefault();
    setQrValue(
      `upi://pay?pa=${process.env.NEXT_PUBLIC_UPI}&pn=Jatin%20Sharma&am=${amount}&purpose=nothing&cu=INR`
    );
  };

  return (
    <motion.div
      initial="hidden"
      animate="visible"
      exit="hidden"
      variants={FadeContainer}
      className="fixed inset-0 bg-black/70 grid place-items-center z-50"
    >
      <motion.div
        initial="hidden"
        animate="visible"
        variants={fromTopVariant}
        exit="hidden"
        className="m-5 w-[90%] relative  rounded-lg px-5 py-5 max-w-md bg-white dark:bg-darkSecondary"
      >
        <button title="Back" onClick={close}>
          <IoMdArrowRoundBack className="icon m-0" />
        </button>

        {!qrValue ? (
          <>
            <form
              onSubmit={generatePaymentQR}
              className="flex flex-col gap-5 my-5 mb-10"
            >
              <div className="relative flex items-center justify-center">
                <BiRupee className="h-full w-9 -ml-1  text-gray-600 dark:text-gray-200" />
                <input
                  onInput={(e) => {
                    if (e.target.value.length === 0)
                      return (e.target.style.width = "3ch");
                    if (e.target.value.length > 7) return;
                    e.target.style.width = e.target.value.length + "ch";
                  }}
                  title="Enter amount"
                  id="amount"
                  className="bg-transparent rounded-lg dark:placeholder-gray-400 text-gray-600 dark:text-gray-200 outline-none font-bold text-2xl w-[3ch]"
                  type="number"
                  name="amount"
                  placeholder="500"
                  min="100"
                  max="1000000"
                  required
                  value={amount}
                  onChange={(e) => setAmount(e.target.value)}
                />
              </div>
              <input type="submit" value="" hidden />
            </form>

            {amount >= 100 && (
              <motion.button
                onClick={generatePaymentQR}
                initial="hidden"
                animate="visible"
                variants={popUp}
                type="submit"
                className="px-4 py-1.5 w-9/12 sm:w-1/2 flex justify-center mx-auto rounded-lg font-semibold bg-black text-white dark:bg-white dark:text-black clickable_button"
              >
                Pay{" "}
                {amount && (
                  <span className="ml-2 truncate">&#8377; {amount}</span>
                )}
              </motion.button>
            )}
          </>
        ) : (
          <AnimatedDiv
            variants={FadeContainer}
            className="flex flex-col items-center"
          >
            <QRCode
              className="mx-auto scale-75"
              id="QRCode"
              value={qrValue}
              bgColor={isDarkMode ? "#25282a" : "white"}
              fgColor={isDarkMode ? "white" : "#25282a"}
            />

            <div className="flex justify-center  items-center gap-2 text-gray-600 dark:text-gray-200 text-sm my-5">
              <FiInfo className="w-5 h-5" />
              <p className="text-xs">Scan the QR code via any UPI app </p>
            </div>
          </AnimatedDiv>
        )}
      </motion.div>
    </motion.div>
  );
}
Enter fullscreen mode Exit fullscreen mode

创建支持内容

它是带有图标和 URL 的静态内容,用于支持组件。

/* Filename: content/support.js */

import { SiBuymeacoffee } from "react-icons/si";
import { BsPaypal } from "react-icons/bs";

module.exports = [
  {
    name: "Buy Me a Coffee",
    url: "https://buymeacoffee.com/j471n",
    Icon: SiBuymeacoffee,
  },
  {
    name: "PayPal",
    url: "https://paypal.me/j47in",
    Icon: BsPaypal,
  },
];
Enter fullscreen mode Exit fullscreen mode

FramerMotion 自定义组件

在本节中,我将提到一些framer-motion可以动画等的自定义div组件p

动画按钮

/* Filename: components/FramerMotion/AnimatedButton.js */

import { motion } from "framer-motion";

export default function AnimatedButton({
  onClick,
  infinity,
  className,
  children,
  variants,
}) {
  return (
    <motion.button
      className={className}
      initial="hidden"
      onClick={onClick}
      variants={variants}
      whileInView="visible"
      viewport={{ once: !infinity }}
    >
      {children}
    </motion.button>
  );
}
Enter fullscreen mode Exit fullscreen mode

动画Div

/* Filename: components/FramerMotion/AnimatedDiv.js */

import { motion } from "framer-motion";

export default function AnimatedDiv({
  variants,
  className,
  children,
  infinity,
  style,
}) {
  return (
    <motion.div
      initial="hidden"
      whileInView="visible"
      viewport={{ once: !infinity }}
      variants={variants}
      className={className}
      style={style}
      transition={{ staggerChildren: 0.5 }}
    >
      {children}
    </motion.div>
  );
}
Enter fullscreen mode Exit fullscreen mode

动画标题

/* Filename: components/FramerMotion/AnimatedHeading.js */

import { motion } from "framer-motion";

export default function AnimatedHeading({
  variants,
  className,
  children,
  infinity,
}) {
  return (
    <motion.h1
      initial="hidden"
      whileInView="visible"
      viewport={{ once: !infinity }}
      variants={variants}
      className={className}
    >
      {children}
    </motion.h1>
  );
}
Enter fullscreen mode Exit fullscreen mode

动画输入

/* Filename: components/FramerMotion/AnimatedInput.js */

import { motion } from "framer-motion";

export default function AnimatedInput({
  infinity,
  className,
  variants,
  options,
  onChange,
}) {
  return (
    <motion.input
      initial="hidden"
      whileInView="visible"
      viewport={{ once: !infinity }}
      variants={variants}
      className={className}
      onChange={onChange}
      {...options}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

动画链接

/* Filename: components/FramerMotion/AnimatedLink.js */

import { motion } from "framer-motion";

export default function AnimatedLink({ variants, infinity, children }) {
  return (
    <motion.a
      initial="hidden"
      whileInView="visible"
      variants={variants}
      viewport={{ once: !infinity }}
    >
      {children}
    </motion.a>
  );
}
Enter fullscreen mode Exit fullscreen mode

动画文本

/* Filename: components/FramerMotion/AnimatedText.js */

import { motion } from "framer-motion";

export default function AnimatedText({
  variants,
  className,
  children,
  infinity,
}) {
  return (
    <motion.p
      initial="hidden"
      whileInView="visible"
      viewport={{ once: !infinity }}
      variants={variants}
      className={className}
    >
      {children}
    </motion.p>
  );
}
Enter fullscreen mode Exit fullscreen mode

动画文本区域

/* Filename: components/FramerMotion/AnimatedTextArea.js */

import { motion } from "framer-motion";

export default function AnimatedTextArea({
  infinity,
  className,
  variants,
  options,
  onChange,
}) {
  return (
    <motion.textarea
      whileInView="visible"
      initial="hidden"
      viewport={{ once: !infinity }}
      variants={variants}
      className={className}
      onChange={onChange}
      {...options}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

添加站点地图

站点地图是一个文件,其中包含您网站上的页面、视频和其他文件的信息以及它们之间的关系。Google 等搜索引擎会读取此文件,以便更高效地抓取您的网站。

首先安装globbyprettier

pnpm i globby prettier
Enter fullscreen mode Exit fullscreen mode

创造lib/sitemap.js

/* Filename: lib/sitemap.js */

import { writeFileSync } from "fs";
import { globby } from "globby";
import prettier from "prettier";

export default async function generate() {
  const prettierConfig = await prettier.resolveConfig("./.prettierrc.js");
  const pages = await globby([
    "pages/*.js",
    "posts/*.mdx",
    "!pages/_*.js",
    "!pages/api",
    "!pages/404.js",
  ]);

  const sitemap = `
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
        ${pages
          .map((page) => {
            const path = page
              .replace("pages", "")
              .replace("posts", "/blogs")
              .replace(".js", "")
              .replace(".mdx", "");
            const route = path === "/index" ? "" : path;

            return `
              <url>
                  <loc>${`https://j471n.in${route}`}</loc>
              </url>
            `;
          })
          .join("")}
    </urlset>
    `;

  const formatted = prettier.format(sitemap, {
    ...prettierConfig,
    parser: "html",
  });

  // eslint-disable-next-line no-sync
  writeFileSync("public/sitemap.xml", formatted);
}
Enter fullscreen mode Exit fullscreen mode

上面的代码创建了一个站点地图,并将文件保存在public目录中。现在我们只需要调用这个函数,就可以将其保存为 RSS。

/* Filename: pages/index.js */

/* .......... */
import generateSitemap from "@lib/sitemap";

export async function getStaticProps() {
  // ........
  await generateSitemap(); // calling to generate the sitemap
  // ........
}
Enter fullscreen mode Exit fullscreen mode

这就是生成网站站点地图所需的全部步骤。所有步骤现已完成。PWA 是可选的。我将此作品集设计为Progressive Web App

生成 PWA

渐进式 Web 应用 (PWA) 是一种外观和行为与移动应用类似的网站。PWA 旨在充分利用移动设备的原生功能,而无需最终用户访问应用商店。

你可以参考以下文章来构建 PWA,我在其中解释了如何将你的网站构建为 PWA。在继续之前,你需要先阅读以下博客。

在您根据上述文章的帮助成功创建 PWA 网站后,我将进一步深入讲解。让我们看看我究竟在说什么。

脉冲式水射流

首先,您可以选择安装此网站,然后随着您深入了解更多细节,您会看到我添加的一些屏幕截图,它们展示了这个网站是什么。安装完成后,您还可以在按住应用程序时访问快捷菜单。我们将逐一创建这些功能。

添加屏幕截图

manifest.json我想你已经在目录中public。你只需要截取一些 Web 应用的屏幕截图,并将它们添加到public/screenshots文件夹中。它看起来会像这样:

my-project/
└── public/
    ├── fonts/
    └── screenshots/
        ├── blog.png
        ├── contact.png
        ├── home.png
        ├── projects.png
        ├── stats.png
        └── utilities.png
Enter fullscreen mode Exit fullscreen mode

现在您已将这些添加到文件夹中,现在您只需将这些路径添加到manifest.json如以下代码所示:

/* Filename: public/manifest.json */

{
  "theme_color": "#000",
  /* .......Other properties... */
  "icons": [
    /* ....Icons...... */
  ],
  "screenshots": [
    {
      "src": "screenshots/home.png",
      "sizes": "360x721",
      "type": "image/gif"
    },
    {
      "src": "screenshots/blogs.png",
      "sizes": "360x721",
      "type": "image/gif"
    },
    {
      "src": "screenshots/projects.png",
      "sizes": "360x721",
      "type": "image/gif"
    },
    {
      "src": "screenshots/stats.png",
      "sizes": "360x721",
      "type": "image/gif"
    },
    {
      "src": "screenshots/utilities.png",
      "sizes": "360x721",
      "type": "image/gif"
    },
    {
      "src": "screenshots/contact.png",
      "sizes": "360x721",
      "type": "image/gif"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

manifest.json注意:根据图像的分辨率更新尺寸

添加快捷方式

现在与屏幕截图相同将图像添加到public/shortcuts目录:

my-project/
└── public/
    ├── fonts/
    ├── screenshots/
    └── shortcuts/
        ├── about.png
        ├── blog.png
        └── newsletter.png
Enter fullscreen mode Exit fullscreen mode

添加快捷方式至manifest.json

/* Filename: public/manifest.json */

{
  "theme_color": "#000",
  /* .......Other properties... */
  "icons": [
    /* ....Icons...... */
  ],
  "screenshots": [
    /* .....Screenshots........ */
  ]

  "shortcuts": [
    {
      "name": "Blogs",
      "url": "/blogs",
      "icons": [
        {
          "src": "shortcuts/blog.png",
          "sizes": "202x202",
          "type": "image/png",
          "purpose": "any"
        }
      ]
    },
    {
      "name": "About me",
      "url": "/about",
      "icons": [
        {
          "src": "shortcuts/about.png",
          "sizes": "202x202",
          "type": "image/png",
          "purpose": "any"
        }
      ]
    },
    {
      "name": "Newsletter",
      "url": "/newsletter",
      "icons": [
        {
          "src": "shortcuts/newsletter.png",
          "sizes": "202x202",
          "type": "image/png",
          "purpose": "any"
        }
      ]
    }
  ]
Enter fullscreen mode Exit fullscreen mode

每个快捷方式都有三个属性:

  • name:快捷方式的名称
  • url:快捷方式的 URL 路径
  • icons:有关图标的信息,例如 src、大小、类型和用途。

这就是创建出色的 PWA 所需要做的全部工作。

总结

我知道这篇文章很长。我已经尽力解释了所有我能解释的内容。但由于文章太长,我可能遗漏了一些应该包含的内容。如果您对本文或网站有任何疑问或建议,请在评论区留言,或在Twitter上联系我。您可以在Github 代码库中找到代码。请为代码库点赞 ⭐ 以表达您的喜爱。如果您喜欢这篇文章,别忘了点赞 ❤️。我们下期再见。

🌐 与我联系

Twitter
Github
Instagram
时事通讯
LinkedIn
网站
给我买杯咖啡

文章来源:https://dev.to/j471n/how-i-made-my-portfolio-with-nextjs-2mn3
PREV
编写(干净的)React 代码
NEXT
你用错了表情符号 从示例开始 问题 为什么会出现这种情况? 解决方法 注意事项 结论