---
# System prepended metadata

title: Nuxt3-project6 Notes
tags: [Nuxt3.js]

---

---
tags: Vue.js
---

# Nuxt3-project6 Notes

原始碼：<https://github.com/WangShuan/nuxt3-notes>

線上連結：<https://xuan-nuxt3-note.js-app.life/>

## 建立與啟動 Nuxt 專案

開啟終端機，`cd` 到桌面或任何希望創建該專案的位置，
執行命令：

```shell
npx nuxi init 06-notes
```

完成後，根據提示，
先 `cd` 到專案目錄 `06-notes` 中，
接著執行命令：

```shell
npm install
```

安裝所有依賴項目，
此時會發現專案目錄中**生成了 `node_modules` 資料夾**

確認您的專案已成功安裝好所有依賴後，
即可執行命令：

```shell
npm run dev
```

啟動 Nuxt 應用程序。

## 專案說明

本專案將結合使用 [supabase](https://supabase.com/) 製作一個可登入、註冊的筆記應用程式。

首先請先到[supabase 官網](https://supabase.com/)註冊帳號(可以使用 GitHub 快速註冊)，
接著點擊建立 supabase 專案，輸入隨意的名稱比如 nuxt3-notes 然後設置一個高級密碼、選擇伺服器位置(新加坡或東京)。

## 安裝與設定 supabase

請在專案中執行命令：

```shell
npm install @nuxtjs/supabase --save-dev
```

> 參考說明：<https://supabase.nuxtjs.org/get-started>

接著修改根目錄中的 `nuxt.config.ts` 檔案：

```typescript
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  modules: ['@nuxtjs/supabase'], // 使用 modules
})
```

最後在項目根目錄中新增 `.env` 檔案存放 url 與 key：

```bash
SUPABASE_URL="從 supabase 專案的設定中點選 API 即可看到 Project URL"
SUPABASE_KEY="從 supabase 專案的設定中點選 API 即可看到 Project API keys"
```

## 使用 supabase 中的 Authentication

首先將登入、註冊與登出三種方法作為 composables，
在項目根目錄中新增資料夾 composables，
並在裡面新增檔案 `useAuth.ts` 開始撰寫登入與註冊等事件。

Authentication 說明文檔：<https://supabase.nuxtjs.org/usage/composables/use-supabase-auth-client#signin>

>預設 supabase 的 auth 會開啟信箱驗證的機制，
>註冊後需要到信箱中收信並點擊驗證信箱後才能開通帳號，
>如果希望取消驗證信的機制，可以在 supabase 的專案頁面中點擊 auth ，
>接著點進左側的 Providers 將 Confirm email 關閉後再點擊右下角 Save 即可。

`useAuth.ts` 檔案最終如下：

```typescript
const useAuth = () => {
  const supabase = useSupabaseClient(); // 引入使用 supabase
  const user = useSupabaseUser() // supabase 提供的獲取當前 user 資料
  
  const SignUp = async (userData: UserData, name: string) => { // 註冊
    const { data: u, error } = await supabase.auth.signUp({
      ...userData, // 傳入 email 與 password
      options: { // 可選參數，這邊用來保存用戶姓名
        data: {
          name: name,
        }
      }
    })

    if (error) throw error
    return u
  };

  const LogIn = async (userData: UserData) => { // 登入
    const { data: u, error } = await supabase.auth.signInWithPassword(userData)
    if (error) throw error
    return u
  }

  const LogOut = async () => { // 登出
    const { error } = await supabase.auth.signOut()
    if (error) throw error
  }

  const ResetPassword = async (pwd: string) => { // 重設密碼
    const { data: u, error } = await supabase.auth.updateUser({ password: pwd })
    if (error) throw error
    return u
  }

  return { user, SignUp, LogIn, LogOut, ResetPassword } // 將所有功能導出
}

export default useAuth;
```

接著即可開始建立登入/註冊頁面：
首先於根目錄中新增 pages 資料夾，並新增 `sign-in.vue` 檔案，
於 `sign-in.vue` 檔案中撰寫登入與註冊畫面，
並通過 `const { SignUp, LogIn, user } = useAuth()` 引入登入、註冊方法以及當前用戶資料。

接著新增元件 `Navbar.vue` 放置在 `components` 資料夾中，
在 navbar 中放置一個登出的按鈕 並通過 `const { user, LogOut } = useAuth()` 引入登出方法以及當前用戶資料，
這邊可以通過 `user.user_metadata.name` 獲取當前用戶的姓名(註冊時藉由 `options` 保存的 `name`)，
藉由 user 判斷當前是否有人登入，並將 navbar 右側的登入按鈕改為顯示用戶姓名+分頁選單(所有文章、我的文章、更改密碼、登出)。

## 使用 supabase 中的 database

接下來我們將開始建立筆記，並將筆記保存到 supabase 的 database 中，
在 supabase 中使用的是 `PostgreSQL` 的資料庫，
首先需要進入 supabase 的專案頁面中點擊 `Database`，
接著於 Table 中點擊右上角的 `+ New Table` 建立資料表，
這邊輸入 name 為 `notes`，
然後把 `Enable Row Level Security (RLS)` 取消勾選(這樣所有人都可以公開讀寫此資料表)，
最後設置好 Columns：

- id 用預設的即可
- created_at 也是預設即可
- title 為 text 保存筆記標題
- note 為 text 保存筆記內容
- user_id 為 UUID 保存用戶 id
- user_name 為 text 保存用戶姓名

完成後即可開始建立新增筆記與所有筆記的頁面，
首先於 pages 資料夾中新增檔案 `/note/new.vue` 用作新增筆記的頁面，
接著將首頁 `/pages/index.vue` 拿來用於顯示所有筆記。

以首頁來說，使用 database 獲取所有筆記的方式如下：

```typescript
const isLoading = ref(true) // 建立 isLoading 用於切換顯示載入中畫面
const notes = ref() // 建立 notes 用於存放所有筆記
if (process.client) { // 判斷如果當前是客戶端渲染才執行
  notes.value = await supabase.from('notes') // 使用 supabase.from('table 名稱') 對資料表進行操作
    .select() // 接著用 .select() 方法獲取所有欄位資料
    .order('created_at', { ascending: false }); // 用 order 方法設定排序根據 created_at 遞減
  isLoading.value = false // 將 isLoading 設為結束以顯示資料
}
```

以新增筆記來說，使用 database 插入數據的方式如下：

```typescript
const supabase = useSupabaseClient();
const { user } = useAuth();
const submitNote = async () => {
  if (!notesInput.title || !notesInput.note) { // 用於顯示欄位不得為空的錯誤提示
    errMsg.title = !notesInput.title ? true : false;
    errMsg.note = !notesInput.note ? true : false;
    return;
  }
  errMsg.title = false; // 清除錯誤提示
  errMsg.note = false; // 清除錯誤提示
  
  await supabase.from('notes') // 使用 supabase.from('table 名稱') 對資料表進行操作
    .insert({ // 接著用 .insert() 方法插入資料
      title: notesInput.title, // 傳入 title 欄位的值
      note: notesInput.note, // 傳入 note 欄位的值
      user_id: user.value?.id, // 傳入 user id
      user_name: user.value.user_metadata.name // 傳入 user name
    });
  navigateTo('/'); // 跳轉至首頁
};
```

## middleware 使用路由守衛

在 Nuxt 中可以通過於根目錄中建立 middleware 資料夾新增路由守衛，
舉例來說，如果要新增筆記，由於需保存 user_id 以及 user_name 所以需要確保當前有登入帳號，
此時就可以通過 middleware 來設定路由守衛，將尚未登入的用戶，導連至登入/註冊頁面。

建立方式：
於項目根目錄中新增檔案 /middleware/auth.ts：

```typescript
export default defineNuxtRouteMiddleware(() => {
  const { user } = useAuth();
  if (!user.value) {
    return navigateTo('/sign-in')
  }
})
```

## 專案升級，添加 Editor.js

首先，安裝 Editor.js：
開啟終端機，執行命令 `npm i @editorjs/editorjs --save`

接著安裝 Editor.js 中的段落、標題、圖片上傳等需要用到的工具：
開啟終端機，執行命令 `npm i --save @editorjs/header @editorjs/list @editorjs/paragraph @editorjs/image`

完成後於項目中新增一個元件 `Editor.vue` 用來放編輯器(記得放在 `components` 資料夾裡面)：

```htmlembedded
<template>
  <!-- 新增一個設定好 id 的空元素用來指定 editor.js 生成的地方 -->
  <div id="editorjs-container"></div>
</template>

<script setup>
// 引入 editor.js
import EditorJS from '@editorjs/editorjs';
// 引入安裝的其他工具，這邊安裝最常用的標題、段落文字、列表、圖片上傳這四項
import Header from '@editorjs/header';
import Paragraph from '@editorjs/paragraph';
import List from '@editorjs/list';
import ImageTool from '@editorjs/image';

// 開始創建 editor
const editor = new EditorJS({
  holder: 'editorjs-container', // 這邊傳入上方 template 中設置好的元素 id
  autofocus: true, // 是否要再生成後自動 focus
  minHeight: 0, // 取消整個 editor 的 padding-bottom: 300px
  tools: { // 選用引入的工具
    header: Header, // 標題工具對應 import 的 Header
    paragraph: Paragraph, // 段落公居對應 import 的 Paragraph
    list: List, // 列表工具對應 import 的 List
    image: { // 圖片工具
      class: ImageTool, // 對應 import 的 ImageTool
      config: { // 針對圖片工具的其他設定
        uploader: { 
          // 這裡用來設定圖片檔案上傳功能的 function
        }
      }
    }
  },
});
</script>
```

接著在需要用到 Editor 元件的頁面中使用：

```htmlembedded=
<template>
<!-- 務必要使用 ClientOnly 包裹 Editor 元件，否則重整畫面就會出現 “500 Error Element is not defined” -->
  <ClientOnly>
    <Editor />
  </ClientOnly>
</template>
```

### 使用 storage 上傳&獲取圖片網址

接下來我們將設定上傳圖片的功能，並將圖片保存到 supabase 的 storage 中，
首先需要進入 supabase 的專案頁面中點擊 `Storage`，
接著於 Storage 中點擊左上角的 `+ New bucket` 建立儲存桶，
這邊輸入 name 為 `note-images`，
然後把 `Public bucket` 設為開啟(這樣所有人都可以公開使用此儲存桶)，
完成後即可開始撰寫上傳圖片的 function

Storage 說明文件參考：<https://supabase.com/docs/guides/storage/quickstart>

首先我們使用的圖片工具是：<https://github.com/editor-js/image>

根據該工具的說明，在 image 底下的 uploader 中可設置圖片上傳到後端的功能，
其中又分為通過純網址上傳、通過自定義函數上傳，
這邊我們採用自定義函數上傳的方式撰寫如下：

```javascript
image: {
  class: ImageTool,
  inlineToolbar: true,
  config: {
    uploader: { 
      // 主要新增程式碼
      async uploadByFile(file) { // 函數名稱必須是 uploadByFile
        const fileExtension = file.name.substring(file.name.lastIndexOf('.') + 1); // 獲取副檔名
        const name = `${new Date().getTime()}.${fileExtension}`; // 重設圖片保存的名稱(因為上傳中文檔名會報錯，所以改用當前時間當作檔案名稱)
        const { data } = await supabase.storage // 使用 supabase 提供的 storage API 進行上傳圖片
          .from('note-images') // from 括號中的內容為 Supabase 存儲桶的名稱
          .upload(name, file); // upload 括號中第一個值為 檔案保存的名稱，第二個值為上傳的檔案

        const uploadedImageUrl = await supabase.storage.from('note-images').getPublicUrl(data.path); // 接著通過 getPublicUrl API 獲取圖片對外網址
        return { // 最後回傳成功＆圖片網址用以顯示圖片
          success: true,
          file: {
            url: uploadedImageUrl.data.publicUrl,
          }
        };
      }
    }
  }
}
```

### 保存與顯示 Editor 內容的方法

在新增文章與編輯文章中分別需要保存與顯示 Editor 的內容

#### 保存 Editor 的內容方法

在新增文章中，原本用來保存 Content 的是 textarea，
這邊將 textarea 整塊刪除，改為放置 `<div id="editor-container"></div>`，
並於 script 中設置 editor，
完成後將送出按鈕綁定的事件進行改寫：

```javascript
const submitNote = async () => {
  if (!title.value) {
    errMsg.value = true;
    return;
  }
  errMsg.value = false;
  const savedData = await editor.save(); // 獲取 editor 的內容
  const contentJson = JSON.stringify(savedData); // 將內容轉為 json 格式保存到資料庫中以便下次讀取使用
  
  let note = ''; // 新增空字符串用以保存要顯示的 html 內容
  savedData.blocks.forEach(item => { // savedData.blocks 是每個新增的 editor 內容區塊，通過遍歷循環改成 html 格式
    if (item.type === "paragraph") { // 判斷如果類型為 paragraph 段落，則回傳 p 標籤
      note += `<p>${item.data.text}</p>`;
    }
    if (item.type === 'image') { // 判斷如果類型為 image 圖片，則根據是否有邊框、滿版寬、背景，添加 class 並回傳 img 標籤
      let classes = 'img-fluid';
      if (item.data.withBorder) {
        classes += ' border';
      }
      if (item.data.stretched) {
        classes += ' w-100';
      }
      note += `<div class="image-block${item.data.withBackground ? ' img-withBackground' : ''}">
          <img class="${classes}" src="${item.data.file.url}" alt="${item.data.caption}" />
        </div>
        <p class="image-description">${item.data.caption}</p>`;
    }
    if (item.type === "header") { // 判斷如果類型為 header 標題，則根據其 level 回傳對應的 h1~h6 標籤
      const tag = `h${item.data.level}`;
      note += `<${tag} class="fs-${item.data.level}">${item.data.text}</${tag}>`;
    }
    if (item.type === 'list') { // 判斷如果類型為 list 列表，則根據其 style 回傳對應的 ol 或 ul 標籤
      const tag = item.data.style === 'unordered' ? 'ul' : 'ol';
      const lis = '';
      item.data.items.forEach(li => lis += `<li>${li}</li>`);
      note += `<${tag}>
        ${lis}
      </${tag}>`;
    }
  });

  await supabase
    .from('notes')
    .insert({
      title: title.value,
      note: note, // 將 html 內容保存到 note 中
      content: contentJson, // 在 Table 中新增 column 名為 content 保存 editor 完整的內容
      user_id: user.value?.id,
      user_name: user.value.user_metadata.name
    });

  router.push('/');
};
```

最後將原本的 `NoteCard.vue` 以及顯示文章的 `/notes/[id].vue` 檔案，
改為用 v-html 顯示 note 內容即完成(原本是用 textarea 保存並用 pre 顯示純文字)。

#### 顯示 Editor 的內容方法

在保存 Editor 的內容時通過 `editor.save()` 獲取到了所有數據資料，
並藉由 JSON 格式保存到資料庫中。

顯示則將提取到的內容重新轉為 JSON 格式，
並通過在 `new EditorJS({})` 中添加 `data` 選項為轉換後的 JSON 內容即完成。

這邊我們將稍早新增的 Editor.vue 元件進行改寫，
並在 `/pages` 中新增檔案 `/edit/[id].vue` 用來進行編輯文章的功能，
於編輯文章時取用顯示 Editor 的內容方法再結合保存 Editor 的內容方法完成整個專案升級。

supabase 中編輯資料庫的方法：

```javascript
await supabase
  .from('notes')
  .update({ // 通過 update 方法傳入需要更新的欄位與對應的內容
    title: title.value,
    note: content,
    content: contentJson,
  })
  .eq('id', route.params.id); // 指定更新的項目為 id 相等者
```
