iT邦幫忙

2022 iThome 鐵人賽

DAY 22
0
Modern Web

Nuxt 3 學習筆記系列 第 22

[Day 22] Nuxt 3 實作部落格 - 導覽列模板與新增文章

  • 分享至 

  • xImage
  •  

前言

上一篇,我們完成了基本的會員登入,接下來將進入網站的切版,這篇文章會使用布局模板來實現,上方導覽列與下方顯示網站內容的排版方式,接下來就會快速的進入到新增一篇部落格的文章,該如何實現 Server API 與前端進行串接。

預設布局模板

首先,建立一個預設布局模板,我們預計使每個頁面於上方顯示導覽列,下方則是依據不同的頁面來顯示,大概如下所示。

+---------------------------+
| +-----------------------+ |
| | 導覽列                 | |
| +-----------------------+ |
| +-----------------------+ |
| | 不同頁面的內容顯示的位置  | |
| |                       | |
| |                       | |
| |                       | |
| +-----------------------+ |
+---------------------------+

我們可以建立一個 default.vuelayouts 目錄中,作為預設的佈局模板,模板內約會實作下面的程式碼:

<template>
  <div>
    <header>
      <!-- 導覽列 -->
    </header>
    <slot />
  </div>
</template>

<header> 裡面就是我們可以實作導覽列的位置,下方的 <slot /> 插槽,即會是我們可以放置頁面的容器。

完整的 ./layouts/default.vue 程式碼如下:

<template>
  <div>
    <header class="flex w-full justify-center px-8 xl:px-0">
      <nav class="flex w-full max-w-7xl items-center justify-between py-2">
        <div>
          <a aria-label="TailwindBlog" href="/">
            <div class="flex items-center justify-between">
              <div class="mr-3">
                <Icon class="h-12 w-12" name="logos:nuxt-icon" />
              </div>
              <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">
                Nuxt 3 Blog
              </div>
            </div>
          </a>
        </div>
        <div class="flex items-center text-base leading-5">
          <div class="flex flex-row items-center">
            <NuxtLink
              class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
              to="/login"
            >
              登入
            </NuxtLink>
          </div>
        </div>
      </nav>
    </header>
    <slot />
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const userProfile = computed(() => userStore.profile)
</script>

接著記得調整 app.vue 內容,添加一個 <NuxtLayout> 元件來顯示預設的布局模板,包裹著的 <NuxtPage /> 即會放置於 default.vue 預設插槽之中,如此就能顯示路由的頁面。

<NuxtLayout>
  <NuxtPage />
</NuxtLayout>

至此,我們就完成的第一個導覽列,可以由導覽列提供的「登入」,切換至登入頁面,並且在每個頁面中,都使用預設的布局模板,所以都會顯示導覽列。
https://i.imgur.com/VL5uUdk.gif

取消或替換特定頁面的布局模板

Nuxt 3 布局模板 (Layouts) 這一篇文章內有介紹到,我們可以建立多個布局模板,而 Nuxt 也提供我們可以為頁面元件取消使用或選擇特定的布局模板。

例如,我們想將登入與註冊頁面,取消套用預設的布局模板,我們就可以使用 definePageMeta() 來傳入 layout: false 屬性來取消布局模板的使用。

調整登入頁面 ./pages/login.vue

<script setup>
// ...

definePageMeta({
  layout: false
})
</script>

如此一來,登入頁面將不會使用布局模板,導覽列也就不會顯示。
https://i.imgur.com/j5URyx7.gif

建立使用者選單

為了豐富導覽列,我們可以為已登入的使用者,建立一個使用者頭像,點擊後可以顯示使用者專用的選項,例如登出等功能。

安裝 headless UI

我們的使用的是 Tailwind CSS 來做樣式的處理,headless UI 已經封裝一些實用且好看的元件,元件基於 Tailwind CSS 可以讓我們的風格更一致,也能更彈性的自訂成自己喜歡的樣式。

使用 NPM 安裝 @headlessui/vue

npm install -D @headlessui/vue

調整 nuxt.config,將 @headlessui/vue 新增至 build.transpile 屬性之中。

export default defineNuxtConfig({
  build: {
    transpile: ['@headlessui/vue']
  }
})

建立使用者頭像選單元件

新增 ./components/NavigationBar/NavigationBarAvatarMenu.vue 內容如下:

<template>
  <div>
    <Menu as="div" class="relative inline-block text-left">
      <div>
        <MenuButton class="inline-flex w-full justify-center">
          <img
            class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
            src="https://images.unsplash.com/photo-1577023311546-cdc07a8454d9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=128&q=80"
            alt="使用者選單"
          />
        </MenuButton>
      </div>

      <transition
        enter-active-class="transition duration-100 ease-out"
        enter-from-class="transform scale-95 opacity-0"
        enter-to-class="transform scale-100 opacity-100"
        leave-active-class="transition duration-75 ease-in"
        leave-from-class="transform scale-100 opacity-100"
        leave-to-class="transform scale-95 opacity-0"
      >
        <MenuItems
          class="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
        >
          <div class="px-1 py-1">
            <MenuItem v-slot="{ active }">
              <button
                :class="[
                  active ? 'bg-emerald-500 text-white' : 'text-gray-900',
                  'group flex w-full items-center rounded-md px-2 py-2 text-sm'
                ]"
              >
                <Icon
                  :active="active"
                  class="mr-2 h-5 w-5 text-emerald-400"
                  name="ri:logout-box-line"
                  aria-hidden="true"
                />
                登出
              </button>
            </MenuItem>
          </div>
        </MenuItems>
      </transition>
    </Menu>
  </div>
</template>

<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
</script>

<NavigationBarAvatarMenu> 元件添加至導覽列之中,我們就可以擁有一個使用者頭像的選單囉。
https://i.imgur.com/MJpq994.gif

結合使用者 Store 來控制顯示的時機

使用者頭像選單的元件應該控制於使用者登入之後再顯示,所以我們可以結合 Pinia 進行狀態管理,來檢查使用者資訊的 store 是否具有資料且符合我們的判定依據再進行顯示,否則,我們僅需要渲染出登入的按鈕即可。

例如,我們從 User Store 取出使用者資訊 (Profile),並判斷是否有 id 來決定要顯示使用者頭像選單登入

<template>
  <div>
    <ClientOnly>
      <NavigationBarAvatarMenu v-if="userProfile?.id" />
      <NuxtLink
        v-else
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </ClientOnly>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const userProfile = computed(() => userStore.profile)
</script>

整個完成後,介面看起來就會比較乾淨也符合登入未登入時,應該顯示的介面樣子。
https://i.imgur.com/Ks2b9DK.gif

新增部落格文章

當我們處理好登入後,就開可以開始來實作使用者建立部落格的文章囉!

建立文章資料表

我們的資料庫,是透過 Prisma 的 Schema 來自動產生資料表,我們可以建立如下的 Schema,來作為儲存文章內容的資料表。

model Article {
  id             Int      @id @default(autoincrement())
  title          String
  content        String
  cover          String
  tags           String
  authorId       String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}

欄位相對簡單,各個欄位的用途與說明如下:

  • id: 文章的 ID,採用自動遞增的數字。
  • title: 文章的標題。
  • content: 文章的內容。
  • cover: 文章的封面圖片。
  • tags: 文章的標籤。
  • authorId: 對應 User 資料表的 id 欄位,表示文章的作者。
  • createdAt: 文章建立時間,預設為插入該筆資料的時間。
  • updatedAt: 使用者更新文章資料的時間,預設為更新該筆資料的時間。

另外,我想讓 Article 具有關聯性,所以我們可以使用 Prisma 提供的語法,來建立與 User 的外鍵 (Foreign Key),最後完整的 schema.prisma 如下:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
  relationMode = "prisma"
}

model User {
  id             String   @id @default(uuid())
  providerName   String?
  providerUserId String?
  nickname       String   @default("User")
  email          String   @unique
  password       String?
  avatar         String?
  emailVerified  Boolean  @default(false)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  Article        Article[]
}

model Article {
  id             Int      @id @default(autoincrement())
  title          String
  content        String
  cover          String
  tags           String
  authorId       String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  User           User?    @relation(fields: [authorId], references: [id])
}

記得執行以下指令,來讓 Prisma 建立新的資料表。

npx prisma db push

建立新增文章的 API

建立 ./server/api/manage/articles.post.js,內容如下:

import db from '@/server/db'

export default defineEventHandler(async (event) => {
  const user = event.context?.auth?.user

  if (!user?.id) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  const body = await readBody(event)

  const authorId = user.id

  const articleRecord = await db.article.createArticle({
    title: body.title,
    content: body.content,
    cover: body.cover,
    tags: body.tags,
    authorId
  })

  if (!articleRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Create article failed. Please try again later.'
    })
  }

  return articleRecord
})

整個 Server API 的運作流程如下:

  1. 使用者將欲新增的文章資料以 POST 發送至 /api/manage/article,伺服器中間件,將會解析 JWT 來得到 user
  2. 並判斷 user.id 來決定是否具有權限,如果正確解析 JWT,表示請求為一個已登入的使用者發送,也將放行給予新增文章。
  3. 處理函數將解析 Body 內的資料,並建構出往資料庫新增文章記錄的內容。
  4. 判斷是否新增成功,回傳新增的文章資料。

建立文章的 Prisma Client 操作如下:

async createArticle(options) {
  const articleRecord = await prisma.article
    .create({
      data: {
        title: options.title,
        content: options.content,
        cover: options.cover,
        tags: options.tags,
        authorId: options.authorId
      }
    })
    .catch((error) => {
      console.error(error)
      throw createError({
        statusCode: 500,
        statusMessage: 'Could not create article. Please try again later.'
      })
    })

  return articleRecord
}

我們就完成了建立文章的 API 囉!
https://i.imgur.com/qgBqJKu.gif

取得部落格文章

使用 Prisma Client 來取得文章,也非常方便,如下程式碼,我們就能取出文章資料並以建立時間遞減排序囉!

async getArticles(options = {}) {
  const articleRecords = await prisma.article
    .findMany({
      orderBy: {
        createdAt: 'desc'
      },
      skip: options.pageSize ? options.page * options.pageSize : undefined,
      take: options.pageSize
    })
    .catch((error) => {
      console.error(error)
      throw createError({
        statusCode: 500,
        statusMessage: 'Could not create user. Please try again later.'
      })
    })

  return articleRecords
}

./server/api/articles.get.js 是實作取得文章的 API,我們期望任何人都可以瀏覽這個部落格的文章,所以這隻 API 我們無需驗證使用者即可放行,程式碼如下:

import db from '@/server/db'

export default defineEventHandler(async () => {
  const articlesRecord = await db.article.getArticles()

  return articlesRecord
})

我們也可以在 ./server/api/articles.get.js 處理函數內,添加資料的分頁或為文章資料進行加工,以利前端顯示。
https://i.imgur.com/XicsYeZ.gif

小結

今天我們建立好了部落個的版面,有了導覽列和使用者選單,網站看起來也更專業了一些,結合布局模板,我們可以控制每個頁面所顯示的布局,在未來有更多頁面的時候能具有可控性。最後也實作了新增文章的 API,完成了第一篇文章的新增與瀏覽。


感謝大家的閱讀,這是我第一次參加 iThome 鐵人賽,請鞭小力一些,也歡迎大家給予建議 :)

如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。

範例程式碼


上一篇
[Day 21] Nuxt 3 實作部落格 - 資料庫與會員系統
下一篇
[Day 23] Nuxt 3 實作部落格 - 頁面的導航守衛與切換效果
系列文
Nuxt 3 學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言