---
# System prepended metadata

title: Next.js和Github Issues實作部落格
tags: [前端]

---

# Next.js和Github Issues實作部落格

## 0.執行方式
* 網址：https://blog-example-sooty.vercel.app/
* 本機端測試：http://localhost:3000

Run the development server
```bash
npm run dev
# or
next dev
```
Run production build
```
# build
npx next build
npx next start
```

## 1.專案架構
以Next.js App Router預設架構為基礎
* component：存放可重複使用UI元件
* public: 存放圖片。
* service：存放呼叫API的函式
* types: 存放type/interface
```
root/
   ├── src/
   │   ├── app/
   │   │   ├── post/[id]/
   │   │   │     └── page.tsx
   │   │   ├── redirect/
   │   │   │     └── page.tsx
   │   │   ├── globals.css
   │   │   ├── layout.tsx
   │   │   └── page.tsx
   │   ├── component/ 
   │   ├── public/  
   │   ├── service/  
   │   └── types/
   ├── next.config.mjs
   ├── package.json
   └── tailwind.config.ts
```

## 2.路由

### 動態路由
* url:`/post/[id]`，建立`app/post/[id]`資料夾以及底下的`page.ts`，可以避免重複為每一個id建立頁面。
* dynamicParams: 設為false，在url輸入不存在的id時會回傳404。
* generateStaticParams()會在build time執行，如果是client compoenent就不能用。

```javascript!
import { getData } from "@/service/fetch";

export async function generateStaticParams() {
  const items = await getData();
  return items.map((item) => ({
    id: item.number.toString(),
  }));
}

export const dynamicParams = false;

const Post = ({ params }: { params: { id: String } }) => {
  return(
    <div></div>
  );
}
export default Post;
```


## 3.資料取得及快取
### A. 文章資料快取
* 參考[Data Fetching, Caching, and Revalidating](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#time-based-revalidation)

呼叫Github Issues API使用fetch()，因為Next.js有延伸原生的fetch() Web API，預設在伺服器端進行快取。但是快取後如果Issues內容有更動，也不會重新呼叫fetch()，因此訪客會看到舊的資料。有以下幾種快取策略：
1. 配合CI/CD pipeline重新部署
2. 不進行快取，訪客瀏覽時需要重複呼叫API
3. 定時或按需求重新驗證快取

在部落格的應用場景中，因為作者更新的頻率可能不高，所以採取定時重新驗證的方式，設定為30分鐘內重新呼叫fetch()都會回傳伺服器端記憶體內快取。

> 資料快取架構圖如下，結合伺服器端快取和客戶端狀態管理：

![dataCache](https://hackmd.io/_uploads/BkEfWr1Ra.jpg)


#### 使用環境變數
* 參考[Environment Variables](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables)

Nex.js預設會從`.env.local`檔案將變數載入`process.env`，不用額外安裝dotenv。
```javascript
//.env.local
SECRET=...
```
```javascript
//fetch.tsx
const secret = process.env.SECRET
```

#### 定時重新驗證
* 參考[Time-based Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#time-based-revalidation)
* `user server`宣告為server action
```javascript
'use server';
export async function getData() {
  const url =
   `${process.env.BaseUrl}/
    ${process.env.USER}/
    ${process.env.REPO}/issues`;
  try {
    const res = await fetch(url, {
      method: 'GET', next: { revalidate: 1800 }
    });
    const data = await res.json();
    return data;
  } catch (error) {
    console.log('error', error);
    return undefined;
  }
}
```
#### 客戶端儲存狀態
使用context在客戶端儲存取得的文章資料，避免重複向伺服器端發送請求，流程如下：
1. 第一次啟動DataProvider時，呼叫context內的getData()，取得所有文章編號、標題及內容，並儲存到context。
2. 換頁時改變page，取得新的資料。
3. 文章個別頁面渲染時，從context取得資料。
4. 超過驗證時間重新呼叫Github API取得最新資料，更新context。

雖然客戶端context和伺服器端fetch()都有儲存文章資料，但是如果重新整理頁面會清理掉context，因此仍保留伺服器端的快取。

```javascript
//datacontext.tsx
'use client'
export const DataContext = createContext<DataContextType>({
  //default values
});
export const DataProvider = ({children}: {children: React.ReactNode}) => {
  const [data, setData] = useState<DataType[]>([]); 
  const [page, setPage] = useState(1);
  const [code, setCode] = useState("");
  const pageSize = 10;

  useEffect(() => {
    const fetchData = async () => {
      const result = await getData(page, pageSize) 
      //deal with data
    }
    fetchData()
  }, [page])

  return (
    <DataContext.Provider value={{data, page, setPage, code, setCode}}>
      {children} 
    </DataContext.Provider>
  )
} 
```
在layout加上DataProvider，讓所有頁面(children)都可以存取。
```javascript
//layout.tsx
return (
  <html>
    <body>
      <DataProvider>{children}</DataProvider>
    </body>
  </html>
);

```
在文章頁面從context取得資料。
```javascript
'use client';
import { DataContext } from '@/component/dataContext';
const Post = ({ params }: { params: { id: string } }) => {
  const {data, code} = useContext<DataContextType>(DataContext);
  const record = data.filter(r => r.id === Number(params.id))[0];
	
  return(
    <div>
      <h2>{record.title}</h2>
      <p>{record.body}</p>
    </div>
  );
}
```


### B. Github OAuth驗證及toke快取
* 參考[Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#redirect-urls)

目的為驗證使用者身份，讓OAuth app（這個部落格）代替使用者操作Github Issues，需要access_token才能進行新增、修改、刪除issues。取得步驟如下：
1. GET `https://github.com/login/oauth/authorize?client_id={CLIENT_ID}&scope={SCOPE}`
2. POST `https://github.com/login/oauth/access_token?code={CODE}&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}`

說明：
* client_id: 設定OAuth app時取得，儲存於環境變數
* client_secret: 設定OAuth app時取得，儲存於環境變數
* code: 發送GET之後會轉址回設定的url，並加上query如`/redirect?code=`
* access_token: 發送POST後回傳
* scope: 設定OAuth app可以代替使用者操作的權限

> token快取架構圖如下，簡化交換code、client_id、client_secret的過程：

![tokenCache](https://hackmd.io/_uploads/S1Gy-HJ0a.jpg)


#### 實作
使用者點擊nav元件後會導航到Github的身份驗證頁面。
```javascript
//nav.jsx
const  Nav = () => {
  const url = 
    `${process.env.AuthUrl}/
    ?client_id=${process.env.ClientId}
    &scope=${process.env.SCOPE}
    &redirect_uri=${process.env.RedirectUri}`;
  return (
    <Link href={url}>
      <div>
        <span>登入</span>
      </div>
    </Link>
  );
}
```
授權後轉址回到`/redirect`路由，取得query parameter的code之後呼叫getToken。因為`/redirect`頁面是伺服器端元件，無法將code存到context，所以轉址回首頁（客戶端元件）後傳入code，在首頁將code存入context。
```javascript
const Redirect = async ({searchParams={code: undefined},}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) => {
  const code = searchParams.code;
  if (code !== undefined ) {
    try {
      await getToken(code);
    } catch (error) {
      console.log('redirect error:', error);
    }
  }
  return (
    <>{redirect(`/?code=${code}`)}</>
  );
}
export default Redirect;
```

將呼叫Github API的函式getToken設為server action，在`/redirect`第一次呼叫後會在伺服器端記憶體保存快取，之後需要token時就不用再向Github API發送請求。因安全考量將token的快取保留在記憶體內、不會傳到客戶端；code無法直接用來取得資料，因此存在客戶端的context內。
```javascript
'use server';
export async function getToken(code: string | string[] ) {
  const url = 
    `${process.env.TokenUrl}
    ?client_id=${process.env.ClientId}
    &client_secret=${process.env.ClientKey}
    &code=${code}`;
  try {
    const res = await fetch(url, { 
      method: 'POST', 
      headers: { 'Accept': 'application/json',
      next: { revalidate: 7200 }
    });
    const data = await res.json();
    return data.access_token;
  } catch (error) {
    console.log('postecret error', error);
    return undefined;
  }
}
```

### C. Form提交資料
* 參考[Server Actions and Mutations #forms](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms)

取得token以後，就可以使用提交資料的server action（createIssue）。在createIssue中呼叫getToken，讓token保留在伺服器端。

```javascript
'use server';
export default async function createIssue(code: string, formData: FormData) {
  const url = 
  `${process.env.BaseUrl}/
  ${process.env.USER}/
  ${process.env.REPO}/issues`;
  try {
    const token = await getToken(code);
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github.v3+json',
        contentType: 'application/json',
      },
      body: JSON.stringify({title: formData.get('title'), body: formData.get('body')}),
    });
    const data = await res.json();

    return data;
  } catch (error) {
    console.log('createIssue error', error);
    return undefined;
  }
}
```
在form當中使用server action，可以寫`<form action={createIssue}>`，也可以用event handler。由於提交後要更新context中的資料，因此用handleSubmit處理。

```javascript
'use client';
const Create = ({code}: {code: string}) => {
  const {setData} = useContext<DataContextType>(DataContext);
  
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const res = await createIssue(code, formData);

    //update context
    if (res.number !== undefined) {
      const newData = {id: res.number, title: res.title, body: res.body, created_at: res.created_at};
      setData(prevData => {return[...prevData, newData]});
    }
    setFormData({ title: '', body: '' }); //clear form
  }
  
  return (
    <div>
      <form onSubmit={handleSubmit} method="post">
        <label>Input title</label>
        <input type='text' name='title' required />
        <label>Input content</label>
        <textarea name='body' />
        <button type="submit" onClick={handleClose}>新增</button>
      </form>
    </div>
  );
}
export default Create;

```

## 4.功能及UI元件
### A.渲染markdown
使用[react-markdown](https://remarkjs.github.io/react-markdown/)套件渲染markdown，用以下指令安裝。
```bash
npm install react-markdown
```
需要修改tailwind.config.ts，使用[Tailwind CSS Typography plugin](https://tailwindcss.com/docs/typography-plugin)才能渲染，並且在元件上新增兩個className `prose lg:prose-xl`。由於類別`prose`的預設max-width只有65vw，參考文件中[覆蓋的方法](https://tailwindcss.com/docs/typography-plugin#overriding-max-width)，加上`max-w-none`這個className。

```javascript!
import ReactMarkdown from 'react-markdown';
const Post = ({ params }: { params: { id: String } }) => {
  //fetch data record
  return(
    <div>
      <ReactMarkdown className="prose lg:prose-xl max-w-none">{record && (record.body).toString()}</ReactMarkdown>
    </div>
  );
}
export default Post;

```

### B.Modal
使用MUI的[Backdrop元件](https://mui.com/material-ui/react-backdrop/)，點第一個button後開啟Backdrop內的div。
```javascript
'use client';
import Backdrop from '@mui/material/Backdrop';
const Create = () => {
  const [open, setOpen] = useState(false);
  const handleClose = () => {
    setOpen(false);
  };
  const handleOpen = () => {
    setOpen(true);
  };
  
  return (
    <div>
      <button onClick={handleOpen}>新增</button>
      <Backdrop open={open}>
        <div>
          <button onClick={handleClose}>關閉</button>
        </div>
      </Backdrop>
    </div>
  );
}
export default Create;
}
```

### C.Infinite scroll
使用useRef和IntersectionObserver (Web API)，在要渲染的資料最下方新增一個div作為參照，出現在畫面時會將頁數加一，在DataContext內執行fetch()的useEffect會取得新的資料存入data。

```javascript
//page.tsx
'use client';
import { useContext, useEffect, useState, useRef } from "react";
const Home = () => {
  const reference = useRef<HTMLDivElement | null>(null);
  const {data, setPage, code, setCode, isLast}: DataContextType = useContext(DataContext);
	
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && !isLast) {
        setPage((prevPage) => prevPage + 1);
      }
    }, { threshold: 1 });
    if (reference.current && !isLast) {
      observer.observe(reference.current);
    }
    return () => {
      if (reference.current) {
        observer.unobserve(reference.current);
      }
    }
  }, [reference, isLast]);

  return (
    <main>
      // render data
      <div ref={reference}></div> 
    </main>
  );
}
export default Home;
```
如果回傳的資料長度為0，表示已經沒有更多issues，將isLast設為true讓頁數不會繼續增加。
```javascript
//dataContext.tsx
'use client';
import { createContext, useState, useEffect } from 'react';
import { getData } from '@/service/getData';

export const DataContext = createContext();
export const DataProvider = ({children}) => {
  const [data, setData] = useState<DataType[]>([]); 
  const [page, setPage] = useState(1);
  const [isLast, setIsLast] = useState(false);
  const pageSize = 10;
	
  useEffect(() => {
    const fetchData = async () => {
      const result = await getData(page, pageSize);
      if (result.length > 0) {
        console.log('fetching page', page);
        setData((prevData) => {
          return [...prevData, ...result];
        });
      }
      else {
        setIsLast(true);
      }
    }
    fetchData();
  }, [page]);

  // return DataProvider
}
```

## 5.部署
使用開發Next.js的Vercel公司所提供的託管服務，不需更改`next.config.mjs`也不用撰寫YAML檔，commit到Github之後即可自動部署。需注意取得Github token的`RedirectUri`和`callback URL`，要從localhost改為部署後的URL。
1. 註冊帳號
2. 連結Github
3. 匯入專案
4. 設定環境變數
5. 部署

## 6.優化
用Lighthouse檢視core web vitals分數

![截圖 2024-03-10 下午9.21.36](https://hackmd.io/_uploads/HJvkB4jp6.png)
![截圖 2024-03-10 下午9.25.12](https://hackmd.io/_uploads/HJ-lBVjpT.png)
