xChar

前言

重新开发部署博客到后深刻地体会到 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", // [!code ++]
    }
}

配置 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,具体就不展示这两种用法了

优化样式

最后再美化一下页面布局然后可以增加多主题功能,还有列表懒加载之类的功能

效果

仓库
页面

Loading comments...