前言#
再開発してブログを再展開することで、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 クライアントで、このパッケージを通じて Github Gists の内容を取得してページを生成します。
package.json
に新しいスクリプトを追加します。
{
...
"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;
次に、Nextjs のルーティングページに基づいてpages
ディレクトリを新規作成し、その中に_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;
次に、記録の情報を記述するためのインターフェースを定義し、エクスポートします。
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 内の各ファイルは、設定されたタイプに従って 1 つの記録です。
{
"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>
トークンの取得#
Gist がプライベートの場合は、新しいトークンを作成する必要があります。有効期限は無期限を推奨し、.env
ファイルにも以下の内容を追加します。
GIST_ID=<gist id>
GIT_TOKEN=<token>
もちろん、この部分はコードリポジトリにアップロードしないことをお勧めします。そうしないと、トークンが漏洩する可能性があります。Vercel にホスティングする場合は、設定で環境変数に記入します。
データの取得#
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
を使用する必要がありますが、具体的な使い方はここでは示しません。
スタイルの最適化#
最後に、ページレイアウトを美化し、テーマ機能を追加し、リストの遅延読み込みなどの機能を追加できます。