enpitsulin

enpitsulin

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

使用Nextjs、Github Gists創建閱讀觀影紀錄應用

前言#

重新開發部署博客到後深刻地體會到 Nextjs 這個 react 元框架的優秀之處,於是就基於 Nextjs 創建一個簡單的應用用於記錄自己的讀書觀影記錄

JAMStack#

使用 Nextjs、使用 Github Gists 作為數據來源,使用 windicss 簡化應用樣式

比較簡單的實現了一個JAMStack 結構

創建 Nextjs 應用#

Next 官方並沒有一個 windicss 的模板,我們直接從零開始構建一個項目

創建項目文件夾#

mkdir records
cd records

初始化項目#

pnpm init
git init #或許可選?

安裝依賴#

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

其中 @octokit/core是 github 官方 api 的 js client,我們通過這個包來獲取 Github Gists 的內容來生成頁面

package.json中新增一個 script

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

配置 windicss#

在項目根目錄下新建next.config.js

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

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

然後我們新建一個 pages 目錄用於放置 nextjs 基於約定的路由頁面,裡面新建_app.tsx文件

在頂端引入import 'windi.css'自定義 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>我看過的</title>
        <meta content="width=device-width, initial-scale=1" name="viewport" />
      </Head>
      <Component {...pageProps}></Component>
    </ThemeProvider>
  );
};

export default App;

創建頁面#

在 pages / 下新建一個index.tsx,這是應用的根頁面,我們先創建一個空頁面

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

export default Home;

創建組件#

我們需要一個卡片組件用於展示記錄的信息,新建一個components/card.tsx簡單的設置下卡片樣式

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;

然後我們先定義一個 interface 用於描述記錄的信息,順便導出一下

export interface RecordItem {
  /** 名稱 */
  title: string
  /** 分類 */
  type: 'anime' | 'book' | 'tv' | 'movie'
  /** 發布年份 */
  year: number
  /** 封面圖片url */
  cover: string
  /** 評分 */
  score: 1 | 2 | 3 | 4 | 5
  /** 觀看日期 */
  date: string
  /** 評論 */
  comment: string
}

然後我們借助這個類型將組件完善一下,其中使用 next/image 來圖片優化

如果選擇 SSG 的話,可以直接使用 img 標籤以及把對應的圖片資源放在 public 下做靜態文件服務或者使用圖床鏈接如果準備托管到 Vercel 就直接使用 Image 組件

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

export interface RecordItem {
  /** 名稱 */
  title: string
  /** 分類 */
  type: 'anime' | 'book' | 'tv' | 'movie'
  /** 發布年份 */
  year: number
  /** 封面圖片url */
  cover: string
  /** 評分 */
  score: 1 | 2 | 3 | 4 | 5
  /** 觀看日期 */
  date: string
  /** 評論 */
  comment: string
}

const Score: React.FC<Pick<RecordItem, "score">> = ({ score }) => {
  switch (score) {
    case 1:
      return <big className="font-bold text-gray-500">🍅 爛</big>;
    case 2:
      return <big className="font-bold text-green-500">🥱 無聊</big>;
    case 3:
      return <big className="font-bold text-blue-500">🤔 還行</big>;
    case 4:
      return <big className="font-bold text-violet-500">🤩 值得一看</big>;
    case 5:
      return <big className="font-bold text-orange-500">💯 神作!</big>;
  }
};

const renderType = (type: RecordItem["type"]) => {
  const typeMap = {
    movie: "電影",
    tv: "劇集",
    book: "書籍",
    anime: "動漫"
  };
  return typeMap[type] ?? "未知";
};

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>評分:</span>
            <Score score={props.score} />
          </p>

          <p className="text-base md:text-sm">
            <span>分類:</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>
  );
};

如果使用了 next/image 組件,我們需要修改一下 next.config.js 文件,添加圖片域名配置,添加封面圖片可能的域名

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;

然後可以在 pages/index.tsx 設置看看效果

import Card from 'components/Card.tsx'

const Home: React.FC<Props> = ({ records }) => {
  return (
    <div>
      <Card
        title="獵罪圖鑑"
        type="tv"
        year={2022}
        cover="https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp"
        score={4}
        date="2022-04-02"
        comment="國產刑偵劇,除了最後關於程序員的部分有點讓從業者尷尬,總體感覺值得一看"
      />
    </div>
  )
};

export default Home;

設置和獲取數據#

首先我們以設定好的先新建一個 json 格式的 Gist,我們 gist 中每一個 file 就是一條記錄按照設定的類型如下

{
  "title": "獵罪圖鑑",
  "type": "tv",
  "year": 2022,
  "cover": "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp",
  "score": 4,
  "date": "2022-04-02",
  "comment": "國產刑偵劇,除了最後關於程序員的部分有點讓從業者尷尬,總體感覺值得一看"
}

如果想直接新建多條可以點擊Add file,然後Create public gist或者Create private gist

然後記住這條 Gist 的 id https://gist.github.com/enpitsuLin/<gist id>,新建一個.env文件,添加如下內容

GIST_ID=<gist id>

獲取 token#

如果你的 gist 是 private 的則需要新建 token,過期時間建議不過期,然後也在.env文件中添加如下內容

GIST_ID=<gist id>
GIT_TOKEN=<token>

當然這一部分建議不要上傳至代碼庫,因為這樣會導致你的 token 被洩露,如果使用 Vercel 托管就到 settings 裡寫入 Environment Variables

獲取數據#

新增lib/get-records.ts文件用於獲取數據的邏輯

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
}

這裡有幾種方法在頁面中拿到數據,使用getStaticProps/getServerSideProps,或者在頁面中使用 fetch 或者 xhr 獲取數據並渲染推薦使用 swr

如果目的是創建一個靜態頁面的話只能使用getStaticProps或者在頁面運行是的時候獲取數據

如果是托管在 Vercel 之類的網站托管服務或者自己的服務器部署的話可以使用getServerSideProps

getStaticProps#

使用 getStaticProps 則只能獲取在每次構建的時候的數據,用於 ssg 最好但是數據不及時

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;

使用 getServerSideProps 的話則和 getStaticProps 基本一致但是需要托管在平台上或者自己部署

如果在運行時獲取的話需要在useEffect中獲取或者使用 swr 的useSWR,具體就不展示這兩種用法了

優化樣式#

最後再美化一下頁面佈局然後可以增加多主題功能,還有列表懶加載之類的功能

效果#

倉庫
頁面

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。