enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

Create a reading and viewing record application using Next.js and GitHub Gists

Preface#

After redeveloping and deploying the blog, I deeply realized the excellence of Next.js, this React meta-framework. Therefore, I created a simple application based on Next.js to record my reading and viewing experiences.

JAMStack#

Using Next.js, utilizing GitHub Gists as the data source, and using WindiCSS to simplify application styles.

I have implemented a simple JAMStack structure.

Creating a Next.js Application#

There is no official WindiCSS template from Next.js, so we will build a project from scratch.

Create Project Folder#

mkdir records
cd records

Initialize Project#

pnpm init
git init # optional?

Install Dependencies#

pnpm add next react react-dom @octokit/core
pnpm add -D @types/node @types/react @types/react-dom typescript windicss windicss-webpack-plugin

Among them, @octokit/core is the official JS client for GitHub API. We use this package to fetch the content of GitHub Gists to generate pages.

Add a script in package.json

{
    ...
    "scripts":{
        "dev":"next",
    }
}

Configure WindiCSS#

Create a new next.config.js file in the project root directory.

const WindiCSSWebpackPlugin = require("windicss-webpack-plugin");

/** @type {import('next').NextConfig} */
const config = {
  webpack(config) {
    config.plugins.push(new WindiCSSWebpackPlugin());
    return config;
  }
};
module.exports = config;

Then, create a pages directory to place the Next.js convention-based routing pages, and create a _app.tsx file inside.

Import import 'windi.css' at the top and customize the App.

import "windi.css";
import { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
import Head from "next/head";

const App: React.FC<AppProps> = ({ Component, pageProps }) => {
  return (
    <ThemeProvider attribute="class" defaultTheme="system">
      <Head>
        <title>What I've Watched</title>
        <meta content="width=device-width, initial-scale=1" name="viewport" />
      </Head>
      <Component {...pageProps}></Component>
    </ThemeProvider>
  );
};

export default App;

Create Page#

Create an index.tsx file under pages/, which is the root page of the application. Let's first create an empty page.

const Home: React.FC<Props> = ({ records }) => {
  return <div></div>
};

export default Home;

Create Component#

We need a card component to display the record information. Create a components/card.tsx file and set up the card styles simply.

const Card: React.FC = () => {
  return (
    <section className="pb-10 relative before:(border-l-2 inset-y-0 -left-30px absolute content-open-quote) first:before:top-1 last:before:bottom-10">
        content
    </section>
  );
};
export default Card;

Next, we define an interface to describe the record information and export it.

export interface RecordItem {
  /** Title */
  title: string
  /** Category */
  type: 'anime' | 'book' | 'tv' | 'movie'
  /** Release Year */
  year: number
  /** Cover Image URL */
  cover: string
  /** Rating */
  score: 1 | 2 | 3 | 4 | 5
  /** Viewing Date */
  date: string
  /** Comment */
  comment: string
}

Then we enhance the component using this type, utilizing next/image for image optimization.

If you choose SSG, you can directly use the img tag and place the corresponding image resources in the public directory for static file serving or use image hosting links. If you plan to host on Vercel, just use the Image component.

import Image from "next/image";
import { useState } from "react";

export interface RecordItem {
  /** Title */
  title: string
  /** Category */
  type: 'anime' | 'book' | 'tv' | 'movie'
  /** Release Year */
  year: number
  /** Cover Image URL */
  cover: string
  /** Rating */
  score: 1 | 2 | 3 | 4 | 5
  /** Viewing Date */
  date: string
  /** Comment */
  comment: string
}

const Score: React.FC<Pick<RecordItem, "score">> = ({ score }) => {
  switch (score) {
    case 1:
      return <big className="font-bold text-gray-500">🍅 Bad</big>;
    case 2:
      return <big className="font-bold text-green-500">🥱 Boring</big>;
    case 3:
      return <big className="font-bold text-blue-500">🤔 Okay</big>;
    case 4:
      return <big className="font-bold text-violet-500">🤩 Worth Watching</big>;
    case 5:
      return <big className="font-bold text-orange-500">💯 Masterpiece!</big>;
  }
};

const renderType = (type: RecordItem["type"]) => {
  const typeMap = {
    movie: "Movie",
    tv: "TV Show",
    book: "Book",
    anime: "Anime"
  };
  return typeMap[type] ?? "Unknown";
};

export const Card: React.FC<RecordItem> = (props) => {
  const [loading, setLoading] = useState(true);
  const loadingClasses =
    "backdrop-filter backdrop-grayscale backdrop-blur-lg transform  scale-110 hover:opacity-75 duration-300 ease-in-out";
  const loadedClasses =
    "backdrop-filter backdrop-grayscale-0 backdrop-blur-0 transform  scale-100 hover:opacity-75 duration-300 ease-in-out";
  const classes = loading ? loadingClasses : loadedClasses;

  return (
    <section className="pb-10 relative before:(border-l-2 inset-y-0 -left-30px absolute content-open-quote) first:before:top-1 last:before:bottom-10 ">
      <p className="text-sm mb-2 relative sm:text-base sm:mb-3">
        {new Date(props.date).toLocaleDateString()}

        <i className="rounded-full bg-gray-200 h-4 transform top-1/2 -left-9 w-4 translate-y-[-50%] absolute" />
      </p>
      <div className="flex justify-between">
        <div className="flex-1 mr-2">
          <p className="text-md mb-2 leading-6 sm:mb-3 sm:text-2xl ">
            {props.title}
            <span>{props.year}</span>
          </p>

          <p className="text-base md:text-sm">
            <span>Rating:</span>
            <Score score={props.score} />
          </p>

          <p className="text-base md:text-sm">
            <span>Category:</span>
            {renderType(props.type)}
          </p>

          <div className="mt-4 text-sm md:text-x text-gray-700 dark:text-gray-300">{props.comment}</div>
        </div>
        <div className="rounded-xl w-87px overflow-hidden md:rounded-md">
          <Image
            src={props.cover}
            layout="fixed"
            width={87}
            height={116}
            objectFit="cover"
            alt={props.title}
            className={classes}
            onLoadingComplete={() => setLoading(false)}
          />
        </div>
      </div>
    </section>
  );
};

If you use the next/image component, we need to modify the next.config.js file to add image domain configuration for possible cover image domains.

const WindiCSSWebpackPlugin = require("windicss-webpack-plugin");

/** @type {import('next').NextConfig} */
const config = {
  webpack(config) {
    config.plugins.push(new WindiCSSWebpackPlugin());
    return config;
  },
  images: {
    domains: ["img1.doubanio.com", "img2.doubanio.com", "img3.doubanio.com", "img9.doubanio.com"]
  }
};
module.exports = config;

Then we can set up the effect in pages/index.tsx.

import Card from 'components/Card.tsx'

const Home: React.FC<Props> = ({ records }) => {
  return (
    <div>
      <Card
        title="The Hunt for Crime"
        type="tv"
        year={2022}
        cover="https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp"
        score={4}
        date="2022-04-02"
        comment="A domestic criminal investigation drama. Except for the part about programmers at the end, which is a bit awkward for professionals, overall it feels worth watching."
      />
    </div>
  )
};

export default Home;

Setting and Fetching Data#

First, we will create a new JSON format Gist as per the established format. Each file in our gist is a record according to the set type as follows:

{
  "title": "The Hunt for Crime",
  "type": "tv",
  "year": 2022,
  "cover": "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp",
  "score": 4,
  "date": "2022-04-02",
  "comment": "A domestic criminal investigation drama. Except for the part about programmers at the end, which is a bit awkward for professionals, overall it feels worth watching."
}

If you want to create multiple entries directly, you can click Add file, then Create public gist or Create private gist.

Remember the Gist ID https://gist.github.com/enpitsuLin/<gist id>, create a .env file, and add the following content:

GIST_ID=<gist id>

Get Token#

If your gist is private, you will need to create a token. It is recommended that the expiration time does not expire, and then also add the following content to the .env file.

GIST_ID=<gist id>
GIT_TOKEN=<token>

Of course, this part is recommended not to upload to the code repository, as it would expose your token. If you are hosting on Vercel, write it in the settings under Environment Variables.

Fetch Data#

Create a new lib/get-records.ts file for the logic to fetch data.

import { Octokit } from '@octokit/core'

const octokit = new Octokit({ auth: process.env.GIT_TOKEN })

export async function getRecords() {
    const res = await octokit.request("GET /gists/{gist_id}", { gist_id: process.env.GIST_ID })
    return res
}

There are several ways to get data on the page, using getStaticProps/getServerSideProps, or fetching data in the page using fetch or xhr and rendering it. It is recommended to use swr.

If the goal is to create a static page, you can only use getStaticProps or fetch data when the page is running.

If hosted on platforms like Vercel or your own server, you can use getServerSideProps.

getStaticProps#

Using getStaticProps can only fetch data at build time, which is best for SSG but not timely.

import { Card } from "components/Crad";
import { GetStaticProps } from "next";
import { getRecords } from "lib/get-records";
import { RecordItem } from "types/records";

interface Props {
  records: RecordItem[];
}

function filterTruthy<T>(x: T | false): x is T {
  return Boolean(x);
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const { data } = await getRecords();
  const records = Object.keys(data.files)
    .map((key) => {
      try {
        return JSON.parse(data.files[key].content) as RecordItem;
      } catch (error) {
        return false;
      }
    })
    .filter(filterTruthy);

  return {
    props: {
      records: records.sort((a, b) => {
        return new Date(a.date) < new Date(b.date) ? 1 : -1;
      })
    }
  };
};

const Home: React.FC<Props> = ({ records }) => {
  return (
    <div>
      {records.map((record) => (
        <Card {...record} key={record.title} />
      ))}
    </div>
  );
};

export default Home;

Using getServerSideProps is basically the same as getStaticProps, but it needs to be hosted on a platform or self-deployed.

If fetching at runtime, you need to fetch in useEffect or use swr's useSWR. The specifics of these two methods will not be shown here.

Optimize Styles#

Finally, beautify the page layout and add multi-theme functionality, as well as features like lazy loading of the list.

Effect#

Repository
Page

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.