長話短說

在本文中,我們將使用 WebSockets 在 Next.js 中建立即時看板,並提供資料庫支援、透過 Vercel AI SDK 提供的 AI 支援以及透過 Tolgee 的在地化。

你將學到什麼:✨

  • 在沒有 Express 的Next.js中設定 WebSocket 伺服器。

  • 使用NextAuth在 Next.js 中實現基於憑證的身份驗證。

  • 使用 Docker 或雲端提供者配置 PostgreSQL 資料庫。

  • 將任務描述的 AI 支援與Vercel AI SDK整合。

  • 使用Tolgee新增即時翻譯和在地化。

{% cta https://tolg.ee/iof9mi %}為 Tolgee 儲存庫加註星標 ⭐{% endcta %}

您準備好建立具有人工智慧和本地化支援的獨特看板了嗎? 🔥

準備好了 GIF


設定專案🛠️

初始化 Next.js 應用程式

使用以下命令初始化新的 Next.js 應用程式:

ℹ️ 您可以使用您選擇的任何套件管理器。對於這個專案,我將使用 npm。

npx create-next-app@latest kanban-ai-realtime-localization --typescript --tailwind --eslint --app --src-dir --use-npm

接下來,導覽至新建立的 Next.js 專案:

cd kanban-ai-realtime-localization

安裝依賴項

我們需要幾個依賴項。執行此命令來安裝我們專案所需的所有依賴項:

npm install @ai-sdk/openai @tolgee/react @tolgee/web @tolgee/format-icu @tanstack/react-query @prisma/client ai socket.io socket.io-client prisma next-auth date-fns nodemon ts-node zod tsconfig-paths react-beautiful-dnd

設定 UI 元件

對於 UI 元件,我們將使用shadcn/ui 。使用以下命令使用預設設定對其進行初始化:

npx shadcn@latest init -d

現在,讓我們加入一些稍後將在應用程式中使用的 UI 元件。若要從shadcn/ui新增可重複使用元件,請執行以下命令:

npx shadcn@latest add button card input label select textarea toast

app/components/ui目錄中,將為這些元件加入一些附加文件,我們在為應用程式建立 UI 時將使用這些文件。


設定資料庫模型📦

初始化 Prisma

使用以下命令初始化 Prisma:

npx prisma init

執行此指令後,應在專案根目錄的prisma目錄中建立新的schema.prisma檔案。

定義 Prisma 架構

修改新建立的schema.prisma檔案以使用 PostgreSQL 作為資料庫並包含使用者和任務模型。

// 👇 prisma/schema.prisma

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

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: <https://pris.ly/cli/accelerate-init>

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

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id       String @id @default(cuid())
  email    String @unique
  password String

  tasks Task[] @relation("UserTasks")

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Task {
  id          String  @id @default(cuid())
  title       String
  description String?
  userId      String

  column Int
  order  Int

  createdBy User @relation("UserTasks", fields: [userId], references: [id])

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

這個模型很簡單:每個使用者可以有多個任務,每個任務都連結到一個特定的使用者。任務有一個表示其狀態的整數column值(0 表示正在進行,1 表示待處理,2 表示已完成)。 order值決定每個任務在其指定列中的位置。

現在我們已經準備好了模型,我們需要將其推送到資料庫中。為此,我們需要連接 URL。

如果您已經可以使用 Neon 或其他服務存取資料庫,那就太好了。使用連接 URL 填入.env檔。您不需要使用 docker 在本機設定資料庫。


使用 Docker 在本機設定資料庫 🐳

如果您只是想使用 Docker 嘗試使用本機 PostgreSQL 資料庫來完成該專案,請將一個名為DATABASE_URL的新變數以及此連接字串值新增至.env檔案。

// 👇 .env

# If you are using local DB with docker
DATABASE_URL=postgresql://postgres:password@localhost:5432/kanban-board

要在本機上執行資料庫,請確保安裝了 Docker。在專案根目錄中建立一個名為scripts新目錄,並新增一個名為start-local-db-docker.sh的文件,其中包含以下程式碼行:

# 👇 scripts/start-local-db-docker.sh

#!/usr/bin/env bash

# place this in .env: DATABASE_URL=postgresql://postgres:password@localhost:5432/kanban-board
DB_CONTAINER_NAME="kanban-board"

if ! [ -x "$(command -v docker)" ]; then
  echo -e "Docker is not installed. Please install docker and try again."
  exit 1
fi

if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then
  echo "Database container '$DB_CONTAINER_NAME' is already running"
  exit 0
fi

if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
  docker start "$DB_CONTAINER_NAME"
  echo "Existing database container '$DB_CONTAINER_NAME' has been started"
  exit 0
fi

# import env variables from .env
set -a
source ../.env

# Extract components from DATABASE_URL
PROTO="$(echo $DATABASE_URL | grep :// | sed -e's,^\\(.*://\\).*,\\1,g')"
URL="$(echo ${DATABASE_URL/$PROTO/})"
USERPASS="$(echo $URL | grep @ | cut -d@ -f1)"
HOSTPORT="$(echo ${URL/$USERPASS@/} | cut -d/ -f1)"
DB_HOST="$(echo $HOSTPORT | cut -d: -f1)"
DB_PORT="$(echo $HOSTPORT | cut -d: -f2)"
DB_USER="$(echo $USERPASS | cut -d: -f1)"
DB_PASSWORD="$(echo $USERPASS | cut -d: -f2)"

# Debugging information
echo "Extracted DB_HOST: $DB_HOST"
echo "Extracted DB_PORT: $DB_PORT"
echo "Extracted DB_USER: $DB_USER"
echo "Extracted DB_PASSWORD: $DB_PASSWORD"

if [ "$DB_PASSWORD" = "password" ]; then
  echo "You are using the default password"
  read -p "Should we generate a random password for you? [y/N]: " -r REPLY
  if [[ $REPLY =~ ^[Yy]$ ]]; then
    # Generate a random URL-safe password
    DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
    sed -i -e "s#:password@#:$DB_PASSWORD@#" ../.env
  else
    echo "Please set a password in the `.env` file and try again"
    exit 1
  fi
fi

echo "Starting the container on port $DB_PORT"

docker run -d \\
  --name $DB_CONTAINER_NAME \\
  -e POSTGRES_USER="$DB_USER" \\
  -e POSTGRES_PASSWORD="$DB_PASSWORD" \\
  -e POSTGRES_DB="$DB_CONTAINER_NAME" \\
  -p "$DB_PORT:5432" \\
  docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"

該腳本基本上讀取DATABASE_URL變數的.env文件,並提取所有相關資料,如使用者名稱、密碼、資料庫名稱,並建立一個容器(如果不存在)。如果已經這樣做了,它只會旋轉現有的容器。

執行此腳本來建立並執行一個 PostgreSQL 容器,該容器將託管我們應用程式的所有使用者資料。

bash scripts/start-local-db-docker.sh

現在,我們應該有一個執行 PostgreSQL 的容器。您可以透過執行以下命令來檢查是否是這種情況:

docker ps | grep "kanban-board"

現在,我們需要一種方法來實例化 Prisma 客戶端以與資料庫互動。

src/db目錄中建立一個新檔案index.ts並新增以下程式碼行:

// 👇 src/db/index.ts

import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

export const db = globalThis.prismaGlobal ?? prismaClientSingleton();

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = db;

我們設定了PrismaClient的單例實例,以確保在您的應用程式中僅建立和重複使用一個實例,這在開發模式下特別有用。

我們現在可以使用導出的常數db與應用程式中的資料庫進行互動。

執行以下命令將架構中的變更推送到資料庫。

npx prisma db push

現在,要讓更新的類型在 IDE 中運作,請執行以下命令以根據更新的架構產生新類型。

npx prisma generate

這就是我們設定應用程式資料庫所需的全部內容。 🥳


設定 Tolgee 進行本地化 🗣️

若要使用 Tolgee 在 Next.js 應用程式中啟用本地化,請按照下列步驟操作:

  1. 建立language.ts

該文件處理語言偵測和 cookie 管理。

// 👇 src/tolgee/language.ts

"use server";

import { detectLanguageFromHeaders } from "@tolgee/react/server";
import { cookies, headers } from "next/headers";
import { ALL_LANGUAGES, DEFAULT_LANGUAGE } from "@/tolgee/shared";

const LANGUAGE_COOKIE = "NEXT_LOCALE";

export async function setLanguage(locale: string) {
  const cookieStore = cookies();
  cookieStore.set(LANGUAGE_COOKIE, locale, {
    // One year
    maxAge: 1000 * 60 * 60 * 24 * 365,
  });
}

export async function getLanguage() {
  const cookieStore = cookies();
  const locale = cookieStore.get(LANGUAGE_COOKIE)?.value;
  if (locale && ALL_LANGUAGES.includes(locale)) {
    return locale;
  }

  // Try to detect language only if in a browser environment
  if (typeof window !== "undefined") {
    const detected = detectLanguageFromHeaders(headers(), ALL_LANGUAGES);
    return detected || DEFAULT_LANGUAGE;
  }

  return DEFAULT_LANGUAGE;
}

setLanguage函數將所選語言 ( locale ) 儲存為有效期為一年的 cookie,讓應用程式記住使用者在會話中的語言首選項。

getLanguage函數檢查 cookie 中儲存的語言。如果找到有效的語言,則傳回該語言;否則,如果在瀏覽器中執行,它會嘗試從瀏覽器標頭中偵測語言。如果偵測失敗或環境不是瀏覽器,則預設為DEFAULT_LANGUAGE

  1. 建立shared.ts

該文件包含用於處理本地化的共享常數和函數,包括獲取用於翻譯的靜態資料

// 👇 src/tolgee/shared.ts

import { FormatIcu } from "@tolgee/format-icu";
import { DevTools, Tolgee } from "@tolgee/web";

const apiKey = process.env.TOLGEE_API_KEY;
const apiUrl = process.env.TOLGEE_API_URL;

export const ALL_LANGUAGES = ["en", "cs", "de", "fr"];

export const DEFAULT_LANGUAGE = "en";

export async function getStaticData(
  languages: string[],
  namespaces: string[] = [""],
) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const result: Record<string, any> = {};
  for (const lang of languages) {
    for (const namespace of namespaces) {
      if (namespace) {
        result[`${lang}:${namespace}`] = (
          await import(`../../messages/${namespace}/${lang}.json`)
        ).default;
      } else {
        result[lang] = (await import(`../../messages/${lang}.json`)).default;
      }
    }
  }
  return result;
}

export function TolgeeBase() {
  return Tolgee().use(FormatIcu()).use(DevTools()).updateDefaults({
    apiKey,
    apiUrl,
    fallbackLanguage: "en",
  });
}

getStaticData函數負責載入特定語言和命名空間的翻譯以預取在地化內容。它按語言和命名空間從messages目錄中獲取 JSON 文件,然後將所有內容捆綁到單個物件中並返回它。

對於我們應用程式中的語言選擇,我們將為使用者提供四種不同的語言選擇(英語、捷克語、法語和德語)。如果您願意,可以新增對其他語言的支援。

在專案根目錄的messages目錄中,我們將為不同的單字和句子儲存不同的靜態資料。

ℹ️您可以在我的儲存庫中找到這些靜態翻譯檔案的連結。該文件中沒有什麼需要解釋的,因為它們是一堆不同其他語言的翻譯句子。

TolgeeBase函數為 Tolgee 設定了處理翻譯的工具。它加入了對 ICU 訊息格式化 ( FormatIcu ) 的支持,並包含用於偵錯的DevTools 。此函數使用環境變數中的 API 金鑰和 URL,並將英文 ( en ) 設定為後備語言。

  1. 更新環境變數

我們使用兩個不同的 env 變數,使用這些 API 金鑰填充.env檔。在 Tolgee 中註冊一個帳戶並存取TOLGEE_API_KEYS ,但對於此應用程式,不需要擁有該 API 金鑰。

// 👇 .env

TOLGEE_API_URL=https://app.tolgee.io

# Optional
TOLGEE_API_KEY=
  1. 建立server.ts

此檔案配置 Tolgee 實例以進行伺服器端渲染,設定翻譯處理。

// 👇 src/tolgee/server.ts

import { TolgeeBase, ALL_LANGUAGES, getStaticData } from "@/tolgee/shared";
import { createServerInstance } from "@tolgee/react/server";
import { getLanguage } from "@/tolgee/language";

export const { getTolgee, getTranslate, T } = createServerInstance({
  getLocale: getLanguage,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  createTolgee: async (locale: any) =>
    TolgeeBase().init({
      // including all locales
      // on server we are not concerned about bundle size
      staticData: await getStaticData(ALL_LANGUAGES),
      observerOptions: {
        fullKeyEncode: true,
      },
      language: locale,
      fetch: async (input, init) =>
        fetch(input, { ...init, next: { revalidate: 0 } }),
    }),
});

此程式碼建立一個用於伺服器端翻譯處理的 Tolgee 實例。首先將getLocale設定為使用getLanguage函數,該函數會擷取使用者的首選語言。然後,在createTolgee中,它透過getStaticData使用所有支援的語言的翻譯資料初始化 Tolgee。

它還將 Tolgee 設定為使用提供的語言(來自getLanguage ),並配置自訂fetch函數以始終透過設定revalidate: 0載入新資料,從而防止快取翻譯請求。

  1. 建立client.ts

這將為客戶端渲染設定 Tolgee 提供者。

// 👇 src/tolgee/client.ts

"use client";

import { TolgeeBase } from "@/tolgee/shared";
import { TolgeeProvider, TolgeeStaticData } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

type Props = {
  language: string;
  staticData: TolgeeStaticData;
  children: React.ReactNode;
};

const tolgee = TolgeeBase().init();

export const TolgeeProviderClient = ({
  language,
  staticData,
  children,
}: Props) => {
  const router = useRouter();

  useEffect(() => {
    const { unsubscribe } = tolgee.on("permanentChange", () => {
      router.refresh();
    });
    return () => unsubscribe();
  }, [router]);

  return (
    <TolgeeProvider
      tolgee={tolgee}
      options={{ useSuspense: false }}
      fallback="Loading..."
      ssr={{
        language,
        staticData,
      }}
    >
      {children}
    </TolgeeProvider>
  );
};

此程式碼設定了一個用於翻譯的客戶端 Tolgee 提供者。 TolgeeProviderClientlanguagestaticDatachildren作為 props,並使用指定的語言和資料初始化 Tolgee。在useEffect內部,它使用permanentChange監聽語言更改,並在語言更新時透過router.refresh()刷新頁面。

最後, TolgeeProvider渲染子項,使用ssr選項預先載入翻譯並在翻譯未立即準備好時顯示「正在載入...」。

  1. layout.tsx中使用TolgeeProviderClient包裝應用程式

最後,使用<TolgeeProviderClient />元件包裝您的應用程式,以確保所有翻譯均可存取。

// 👇 src/app/layout.tsx

import type { Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
import { Navbar } from "@/components/navbar";
import { getLanguage } from "@/tolgee/language";
import { getStaticData } from "@/tolgee/shared";
import { TolgeeProviderClient } from "@/tolgee/client";

// Rest of the code...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const locale = await getLanguage();

  const staticData = await getStaticData([locale, "en"]);
  return (
    <html lang={locale}>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          <TolgeeProviderClient language={locale} staticData={staticData}>
            <Navbar />
            {children}
            <Toaster />
          </TolgeeProviderClient>
        </body>
    </html>
  );
}

首先,我們根據標頭或從函數設定的 cookie 來存取使用者的區域設定。然後我們將該區域設定提供給<html />標記。

這就是我們在 Next.js 應用程式中設定 Tolgee 所需的全部內容。 ✨這將是您在任何 Next.js 應用程式中使用 Tolgee 實作位置所需執行的標準流程。


設定身份驗證🛡️

我們將在我們的應用程式中使用 NextAuth 進行身份驗證。首先,我們先定義一個新的 Zod 模式,我們將使用它來驗證使用者傳遞的資料。

用於驗證的 Zod 架構

定義 Zod 架構 ( AuthSchema ) 以驗證使用者在登入和註冊期間輸入的電子郵件和密碼。這可確保電子郵件格式正確且密碼符合指定的長度要求。

// 👇 src/lib/validators/auth.ts

import { z } from "zod";

export const AuthSchema = z.object({
  email: z.string().email(),
  password: z.string().trim().min(8).max(20),
});

export type TAuthSchema = z.infer<typeof AuthSchema>;

我們要求電子郵件字段是準確的電子郵件地址,而不是任何其他字串,並且我們希望密碼字段的最小長度為 8 個字符,最大長度為 20 個字符。我們將在多個地方使用此驗證模式來驗證登入/註冊表單中使用者傳遞的資料,以檢查其是否符合資格。

NextAuth 配置

您在src/app/api/auth/[...nextauth]下的route.ts中設定 NextAuth,使用CredentialsProvider進行身份驗證。 authorize函數驗證憑證、檢查使用者的存在並驗證密碼。

// 👇 src/app/api/auth/[...nextauth]/route.ts

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcrypt";
import { AuthSchema, TAuthSchema } from "@/lib/validators/auth";
import { db } from "@/db";

const handler = NextAuth({
  session: {
    strategy: "jwt",
  },
  pages: {
    signIn: "/login",
  },
  secret: process.env.NEXTAUTH_SECRET,
  providers: [
    CredentialsProvider({
      name: "Credentials",
      // These are used in the default sign-in page from next-auth.
      credentials: {
        email: {
          label: "Email",
          type: "text",
          placeholder: "[email protected]",
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const payload: TAuthSchema | null =
          credentials?.email && credentials?.password
            ? {
                email: credentials.email,
                password: credentials.password,
              }
            : null;

        if (!payload) return null;

        const validatedFields = AuthSchema.safeParse(payload);

        if (!validatedFields.success) return null;

        const { email: userInputEmail, password: userInputPassword } =
          validatedFields.data;

        const potentialUser = await db.user.findUnique({
          where: {
            email: userInputEmail,
          },
        });

        if (!potentialUser) return null;

        const isCorrectPassword = await compare(
          userInputPassword,
          potentialUser.password,
        );

        if (!isCorrectPassword) return null;

        //Because getting the error in the IDE: _ is assigned a value but never used.
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { password: _, ...userWithoutPassword } = potentialUser;

        return userWithoutPassword;
      },
    }),
  ],
});

export { handler as GET, handler as POST };

authorize功能邏輯負責使用者是否登入。此設定中的函數檢查提供的電子郵件和密碼是否與資料庫中的現有使用者相符。

我們僅使用基於憑證的身份驗證。首先,它使用AuthSchema進行字段驗證來驗證憑證。如果驗證成功,它將透過電子郵件在資料庫中尋找使用者。如果找到用戶,則會將資料庫中的雜湊密碼與輸入的密碼進行比較。如果兩項檢查都通過,則傳回使用者的資料(不包括密碼)。

正如您可能已經猜到的,這裡我們需要在.env檔案中定義NEXTAUTH_SECRET變數。使用這兩個變數填充.env檔:

// 👇 .env

# Rest of the environment variables...

# For running the application locally, set NEXTAUTH_URL to: <http://localhost:3000>
NEXTAUTH_URL=

# Set NEXTAUTH_SECRET to a random cryptographic string.
# For generating a new secret, run: `openssl rand -base64 32`
NEXTAUTH_SECRET=

用戶註冊介面

src/app/api/auth/register/route.ts中,我們建立一個用於使用者註冊的端點,該端點對密碼進行雜湊處理並將使用者資料儲存在資料庫中。然後,我們根據驗證成功返回適當的回應。

// 👇 src/app/api/auth/register/route.ts

import { AuthSchema } from "@/lib/validators/auth";
import { NextRequest, NextResponse } from "next/server";
import { hash } from "bcrypt";
import { db } from "@/db";

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const validatedFields = AuthSchema.safeParse(body);

    if (!validatedFields.success) {
      return NextResponse.json(
        {
          errors: validatedFields.error.flatten().fieldErrors,
        },
        { status: 422 },
      );
    }

    const { email, password } = validatedFields.data;

    const hashedPassword = await hash(password, 12);
    const user = await db.user.create({
      data: {
        email,
        password: hashedPassword,
      },
    });

    return NextResponse.json(user);
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Something went wrong" },
      { status: 500 },
    );
  }
}

在這裡,我們解析從客戶端接收到的資料,並使用我們之前編寫的AuthSchema對其進行驗證。然後,我們建立一個旋轉值為 12 的哈希值。

現在,為了使我們的應用程式更加可靠,讓我們加入一個中間件,該中間件在用戶存取某個路由時檢查 userSession,如果他們未經身份驗證,則不允許他們存取該路由。

路由保護中介軟體

我們增加一個中間件來限制未經身份驗證的用戶存取/kanban路由。

// 👇 src/middleware.ts

export { default } from "next-auth/middleware";

export const config = { matcher: ["/kanban/:path*"] };

在這裡,我們說的是,如果用戶未經身份驗證,則不應存取“/kanban”路線。

我們已經完成了處理身份驗證的後端邏輯。讓我們研究一些客戶端邏輯。


建立導覽列元件

我們的導覽列元件也將由一些較小的元件組成。我們將有一個用於登入、註冊、登出的按鈕和一個允許用戶切換語言的選擇標籤。

讓我們開始研究這些元件!

語言選擇器元件

src/app/components目錄中建立一個新檔案lang-selector.tsx其中包含以下程式碼行:

// 👇 src/app/components/lang-selector.tsx

"use client";

import { useTolgee, useTranslate, T } from "@tolgee/react";
import { setLanguage } from "@/tolgee/language";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
} from "@/components/ui/select";
import {
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";

export const LangSelector = () => {
  const tolgee = useTolgee(["language"]);
  const locale = tolgee.getLanguage();

  const { t } = useTranslate();

  function onSelectChange(value: string) {
    setLanguage(value);
  }

  const languageOptions = [
    { code: "en", label: "English" },
    { code: "cs", label: "Česky" },
    { code: "fr", label: "Français" },
    { code: "de", label: "Deutsch" },
  ];

  return (
    <Select value={locale} onValueChange={onSelectChange}>
      <SelectTrigger className="w-[200px] border rounded-md">
        <SelectValue placeholder={t("select-a-language")} />
        <ChevronDown className="ml-2 w-4 h-4 inline" />
      </SelectTrigger>

      <SelectContent>
        <SelectGroup>
          <SelectLabel className="mb-1">
            <T keyName="language" />
          </SelectLabel>
          {languageOptions.map(({ code, label }) => (
            <SelectItem key={code} value={code}>
              {label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  );
};

該元件應該是非常不言自明的。我們使用 shadcn/ui 提供的<Select />元件來繪製我們擁有的所有可用語言選擇。根據使用者的選擇,我們使用先前在language.ts檔案中使用的setLanguage函數將語言設定為語言。

💡 注意:請注意我們沒有對程式碼中的任何文字進行硬編碼;相反,我們使用 Tolgee 的元件來渲染文字。這樣,當使用者切換語言時,文字也會隨之改變。如果我們對文字進行硬編碼,那麼實作翻譯將是無效的。今後我們將繼續使用這種方法。

我們使用<T />元件和從 Tolgee 的useTranslate掛鉤獲得的t函數來應用翻譯。要了解它們的差異,請存取此處

LogoutBtn 元件

同樣,在此元件目錄中建立一個名為logout-btn.tsx的新文件,其中包含以下程式碼行:

// 👇 src/components/logout-btn.tsx

"use client";

import { signOut } from "next-auth/react";
import { Button, buttonVariants } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { T, useTranslate } from "@tolgee/react";
import { useState } from "react";
import { toast } from "@/hooks/use-toast";
import { LoaderCircle } from "lucide-react";

export const LogoutBtn = () => {
  const router = useRouter();
  const { t } = useTranslate();

  const [isLoading, setIsLoading] = useState<boolean>(false);

  const handleLogout = async () => {
    setIsLoading(true);

    try {
      await signOut();
      router.push("/login");
      router.refresh();
    } catch (error) {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Button
      onClick={handleLogout}
      className={buttonVariants({
        className:
          "text-gray-800 text-md px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
        variant: "secondary",
      })}
      disabled={isLoading}
    >
      {isLoading && (
        <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin mr-2" />
      )}
      <T keyName="logout" />
    </Button>
  );
};

與之前類似,當用戶單擊按鈕時,我們會觸發handleLogout函數,然後該函數會嘗試註銷用戶,如果發生任何錯誤,則會顯示一條toast通知以及翻譯後的錯誤訊息。

當使用者登出時,我們使用載入狀態來顯示載入程式圖示。

導覽列元件

最後,現在我們需要的兩個較小的元件都可用了,讓我們來處理<Navbar />元件。

// 👇 src/components/navbar.tsx

import { ListTodo } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { LogoutBtn } from "@/components/logout-btn";
import { buttonVariants } from "@/components/ui/button";
import { LangSelector } from "@/components/lang-selector";
import { T } from "@/tolgee/server";

export const Navbar = async () => {
  const session = await getServerSession();

  return (
    <nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 sticky top-0 z-50">
      <Link
        href={"/"}
        className="text-xl font-semibold hidden text-gray-800 sm:flex items-center select-none"
      >
        <ListTodo size={30} className="mr-2 inline" />
        <T keyName="kanban" />
      </Link>
      <div className="flex gap-4 ml-auto">
        <LangSelector />
        {session ? (
          <LogoutBtn />
        ) : (
          <>
            <Link
              href="/login"
              className={buttonVariants({
                className:
                  "text-gray-600 text-lg px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
                variant: "outline",
              })}
            >
              <T keyName="login" />
            </Link>
            <Link
              href="/register"
              className={buttonVariants({
                className:
                  "text-gray-600 text-lg px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
                variant: "outline",
              })}
            >
              <T keyName="register" />
            </Link>
          </>
        )}
      </div>
    </nav>
  );
};

此導覽列元件為我們的應用程式建立一個Navbar列。它檢查使用者是否使用getServerSession登入。如果使用者通過身份驗證,則會顯示註銷按鈕。如果沒有,它會顯示用戶登入和註冊的連結。


建立身份驗證頁面

現在,我們已經完成了身份驗證的後端邏輯的處理,並完成了在我們的應用程式中實作 Tolgee。讓我們研究一些客戶端邏輯並建立一些 UI。

登入元件

app/components目錄中,使用下列程式碼行建立一個新檔案login.tsx

// 👇 src/app/components/login.tsx

"use client";

import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { T, useTranslate } from "@tolgee/react";
import { LoaderCircle } from "lucide-react";
import { Label } from "@/components/ui/label";

export const Login = () => {
  const router = useRouter();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [isLoading, setIsLoading] = useState<boolean>(false);

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setIsLoading(true);

    try {
      const response = await signIn("credentials", {
        email,
        password,
        redirect: false,
      });

      if (response?.error) {
        toast({
          title: t("something-went-wrong"),
          variant: "destructive",
        });
      } else {
        router.push("/");
        router.refresh();
      }
    } catch (error) {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-lg max-w-sm w-full">
        <h2 className="text-2xl font-bold mb-6 text-center text-gray-900">
          <T keyName="login" />
        </h2>

        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <Label
              htmlFor="email"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="email" />
            </Label>
            <Input
              type="email"
              name="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder={t("email")}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <div className="mb-6">
            <Label
              htmlFor="password"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="password" />
            </Label>
            <Input
              type="password"
              name="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder={t("password")}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <Button
            type="submit"
            className="w-full bg-gray-600 text-white p-3 rounded hover:bg-gray-700 transition duration-200"
            disabled={isLoading}
          >
            {isLoading && (
              <LoaderCircle className="w-5 h-5 text-gray-300 mr-2 animate-spin" />
            )}
            <T keyName="login" />
          </Button>

          <p className="text-center mt-4">
            <T keyName="dont-have-an-account" />{" "}
            <Link
              href="/register"
              className="text-blue-500 hover:text-blue-600 transition duration-200"
            >
              <T keyName="register" />
            </Link>
          </p>
        </form>
      </div>
    </div>
  );
};

Login元件顯示電子郵件和密碼的登入表單,兩個輸入欄位都充當受控元件。提交表單後,它會從next-auth呼叫signIn來處理身份驗證。如果登入失敗,則會透過 Toast 通知顯示翻譯後的錯誤訊息。成功登入將使用者重新導向到主頁。

我們還有一個單獨的載入狀態變數,用於在用戶登入我們的應用程式時顯示載入動畫圖示。

目前,這只是我們建立的一個元件;它尚未顯示在我們的應用程式中。為此,我們需要在應用程式的app目錄中渲染此元件。

登入頁面路由

src/app/login目錄中,建立一個名為page.tsx的新文件,其中包含以下程式碼行:

// 👇 src/app/login/page.tsx

import { Login } from "@/components/login";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await getServerSession();
  if (session) redirect("/kanban");

  return <Login />;
}

在登入頁面中,我們首先檢查使用者是否有活動會話。如果用戶有活動會話,我們只需將他們重新導向到「/看板」路線(我們將很快實現)。如果使用者沒有活動會話,我們將顯示我們先前建立的<Login />元件。

現在我們已經完成了登入頁面的實作;同樣,讓我們建立註冊頁面。

註冊元件

app/components目錄中,使用下列程式碼行建立一個新檔案register.tsx

// 👇 src/app/components/register.tsx

"use client";

import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { T, useTranslate } from "@tolgee/react";
import { LoaderCircle } from "lucide-react";
import { Label } from "@/components/ui/label";
import axios from "axios";
import { useMutation } from "@tanstack/react-query";

export const Register = () => {
  const router = useRouter();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");

  const { mutate: register, isPending } = useMutation({
    mutationFn: async () => {
      const payload = {
        email,
        password,
      };
      await axios.post("/api/auth/register", payload);
    },
    onSuccess: () => {
      router.push("/login");
      router.refresh();
    },
    onError: (error) => {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        description: t("there-was-a-problem-registering-your-account"),
        variant: "destructive",
      });
    },
  });

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    register();
  }

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-lg max-w-sm w-full">
        <h2 className="text-2xl font-bold mb-6 text-center text-gray-900">
          <T keyName="register" />
        </h2>

        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <Label
              htmlFor="email"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="email" />
            </Label>
            <Input
              type="email"
              name="email"
              placeholder={t("email")}
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full p-3 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>

          <div className="mb-6">
            <Label
              htmlFor="password"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="password" />
            </Label>
            <Input
              type="password"
              name="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder={`${t("password")} (${t("min-length-8")})`}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <Button
            type="submit"
            className="w-full bg-gray-600 text-white p-3 rounded hover:bg-gray-600 transition duration-200"
            disabled={isPending}
          >
            {isPending && (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin mr-2" />
            )}
            <T keyName="register" />
          </Button>

          <p className="text-center mt-4">
            <T keyName="already-have-an-account" />{" "}
            <Link
              href="/login"
              className="text-blue-500 hover:text-blue-600 transition duration-200"
            >
              <T keyName="login" />
            </Link>
          </p>
        </form>
      </div>
    </div>
  );
};

此元件中輸入的電子郵件和密碼用作受控元件,與登入頁面上的類似。在這裡,我們使用 React Query 來簡化發出 POST 請求的流程。這種方法消除了管理載入或錯誤處理的單獨狀態的需要。

當使用者點擊表單中的提交按鈕時,系統會向我們的 API 路由發出 POST 請求,以便在我們先前使用的資料庫中註冊使用者。如果註冊成功,使用者將被重新導向到登入頁面。如果不是,則會顯示一則 Toast 訊息以及翻譯後的錯誤訊息。

當使用者點擊提交按鈕時,POST 請求將發送到我們的 API 路由,以在我們先前設定的資料庫中註冊使用者。註冊成功後,使用者將被重新導向到登入頁面。如果註冊失敗,我們會使用相關鍵顯示一條 toast 訊息,其中包含翻譯後的錯誤訊息。

註冊頁面路由

src/app/register目錄中,建立一個名為page.tsx的新文件,其中包含以下程式碼行:

// 👇 src/app/register/page.tsx

import { Register } from "@/components/register";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await getServerSession();
  if (session) redirect("/kanban");

  return <Register />;
}

設定此頁面後,我們就完成了應用程式的身份驗證流程。現在您應該擁有一個支援身份驗證且支援本地化的工作應用程式。


設定 WebSocket 和 QueryClient 提供者

在本節中,我們將為我們的應用程式設定一個 WebSocket 伺服器。讓我們先建立一個幫助我們存取套接字的函數。

getSocket 函數

src/config目錄中,使用以下程式碼行建立一個新檔案socket.ts

// 👇 src/config/socket.ts

import { io, Socket } from "socket.io-client";

let socket: Socket;

export const getSocket = (): Socket => {
  if (socket) return socket;

  socket = io(process.env.NEXT_PUBLIC_APP_URL as string, {
    autoConnect: false,
  });

  return socket;
};

此程式碼定義了一個函數getSocket ,該函數初始化與環境變數NEXT_PUBLIC_APP_URL中指定的 URL的 Socket.IO客戶端連接,確保套接字僅建立一次。如果套接字已經初始化,它只是傳回現有的套接字實例。

套接字提供者

現在,我們需要管理socket.io連接並為我們的元件提供存取套接字實例的方法。在src/providers目錄中,使用以下程式碼行建立一個新檔案socket-provider.tsx

// 👇 src/providers/socket-provider.tsx

"use client";

import { createContext, ReactNode, useContext, useMemo } from "react";
import { getSocket } from "@/config/socket";
import type { Socket } from "socket.io-client";

interface SocketContextType {
  socket: Socket | null;
}

const SocketContext = createContext<SocketContextType | undefined>(undefined);

export const useSocket = () => {
  const context = useContext(SocketContext);
  if (!context) {
    throw new Error("'useSocket' must be used within a 'SocketProviderClient'");
  }
  return context.socket;
};

export default function SocketProviderClient({
  children,
}: {
  children: ReactNode;
}) {
  const socket = useMemo(() => {
    const socketInstance = getSocket();
    return socketInstance.connect();
  }, []);

  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
}

此程式碼建立一個 React 上下文來管理Socket.IO連接,提供useSocket鉤子來存取套接字實例。 SocketProviderClient使用getSocket函數初始化套接字並連接它,然後將其子級包裝在上下文提供者中以在整個應用程式中共用套接字實例。

現在,我們需要使用此套接字提供者包裝我們的應用程式,以使用 WebSocket 發送和接收資料。

QueryClient 和 SocketProvider

在同一目錄中,建立一個新檔案providers.tsx ,我們將使用該檔案將我們的子元件與來自@tanstack/react-query QueryClientProvider和我們新建立的SocketProviderClient包裝起來。

將以下程式碼行新增至文件:

// 👇 src/providers/providers.tsx

"use client";

import { PropsWithChildren, useState } from "react";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SocketProviderClient from "@/providers/socket-provider";

const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <SocketProviderClient>{children}</SocketProviderClient>
    </QueryClientProvider>
  );
};

export default Providers;

現在,我們需要做的就是用這個<Providers />元件包裝我們的應用程式,該元件將允許存取我們的應用程式套接字和反應查詢支援。

使用提供者包裝應用程式佈局

使用以下程式碼行修改專案根目錄中的layout.tsx

// 👇 src/app/layout.tsx

import type { Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
import { Navbar } from "@/components/navbar";
import Providers from "@/providers/providers";
import { getLanguage } from "@/tolgee/language";
import { getStaticData } from "@/tolgee/shared";
import { TolgeeProviderClient } from "@/tolgee/client";

// Rest of the code...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const locale = await getLanguage();

  const staticData = await getStaticData([locale, "en"]);
  return (
    <html lang={locale}>
      <Providers>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          <TolgeeProviderClient language={locale} staticData={staticData}>
            <Navbar />
            {children}
            <Toaster />
          </TolgeeProviderClient>
        </body>
      </Providers>
    </html>
  );
}

使用Socket.io自訂 Web 伺服器

現在,我們準備好建立自己的Socket.io伺服器。建立一個新檔案server.ts並新增以下程式碼行:

// 👇 server.ts

// NOTE: Always Keep this 'tsconfig-paths' import at the top.
// It allows us to use custom paths and aliases defined in the
// `tsconfig.json` file like '@/db'
import "tsconfig-paths/register";

import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
import { db } from "@/db";
import { Task as TTask } from "@prisma/client";
import { DraggableLocation } from "react-beautiful-dnd";

const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOST || "localhost";
const port = Number(process.env.PORT) || 3000;

const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer);

  io.on("connection", (socket) => {
    console.log(`'${socket.id}' user just connected! ✨`);

    socket.on("disconnect", () => {
      console.log(`'${socket.id}' user just disconnected! 👀`);
    });
  });

  httpServer
    .once("error", (err) => {
      console.error("ERROR: server failure", err);
      process.exit(1);
    })

    .listen(port, () => {
      console.log(`Listening on '<http://$>{hostname}:${port}'`);
    });
});

現在,這個server.ts檔案成為我們應用程式的入口點。我們幾乎可以使用像express.js這樣的後端框架的socket.io伺服器做任何事情。

我們現在可以監聽任何類似於此處監聽「連線」和「斷開連線」的事件。我們將來會修改這個文件來監聽我們的自訂事件。

打字稿伺服器配置

現在,建立一個新檔案tsconfig.server.json ,它將保存特定於我們伺服器的設定。新增以下程式碼行:

// 👇 tsconfig.server.json

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "CommonJS",
      "outDir": "dist",
      "lib": ["es2019"],
      "target": "es2019",
      "isolatedModules": false,
      "noEmit": false,
    },
    "include": ["server.ts"]
  }

tsconfig.server.json檔案擴充了tsconfig.json中的基本 TypeScript 配置,並為我們的專案指定了一些自訂設定。它使用 CommonJS 進行模組輸出,並將編譯後的檔案導向dist目錄。 isolatedModules選項設定為false ,允許可能不是獨立的文件,而noEmitfalse ,允許產生輸出檔。最後,它在編譯過程中只包含server.ts檔。

更新package.json

對於我們的開發伺服器,我們將使用nodemon ,現在我們使用server.ts檔案作為我們的伺服器。因此,將package.json檔案中的腳本修改為:

  // 👇 package.json

  "scripts": {
    "dev": "nodemon",
    "build": "next build && tsc --project tsconfig.server.json",
    "start": "NODE_ENV=production node server.ts",
    "lint": "next lint"
  },
  // Rest of the configuration...

此外,我們需要調整nodemon配置以觀察server.ts檔案中的變化並更改其執行命令。

Nodemon配置

在專案根目錄中建立一個新檔案nodemon.json配置如下:

// 👇 nodemon.json

{
  "watch": ["server.ts"],
  "exec": "ts-node --project tsconfig.server.json server.ts",
  "ext": "js ts"
}

設定看板

最後,現在我們已經完成了董事會的所有準備。讓我們為我們的看板顯示和建立任務。

任務元件

src/components目錄中,使用以下程式碼行建立一個新檔案task.tsx

// 👇 src/components/task.tsx

import { Task as TTask } from "@prisma/client";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { T } from "@tolgee/react";

import { format } from "date-fns";

export const Task = ({ task }: { task: TTask }) => {
  const createdDate = format(new Date(task.createdAt), "hh:mm a, dd MMM yyyy");

  return (
    <Card className="w-full max-w-sm my-2 mx-auto">
      <CardHeader>
        <CardTitle>{task.title}</CardTitle>
      </CardHeader>
      {task.description ? (
        <CardContent>
          <Link
            href={`/kanban/${task.id}`}
            className="text-gray-800 font-semibold underline hover:text-gray-900 underline-offset-2"
          >
            <T keyName="view-description" />
          </Link>
        </CardContent>
      ) : null}
      <CardFooter className="text-sm text-gray-500">
        <span className="font-semibold mr-2">
          <T keyName="created-on" />
          {": "}
        </span>
        {createdDate}
      </CardFooter>
    </Card>
  );
};

我們將使用它來顯示應用程式中的任務。在這裡,我們本質上接受一個任務物件作為道具,並使用 Card 元件以類似卡片的方式呈現任務內容。我們使用date-fns套件以更易讀的方式格式化日期。

新增任務元件

現在,讓我們建立一個可用於為面板新增任務的元件。在src/components目錄中,使用以下程式碼行建立一個新檔案add-task.tsx

// 👇 src/components/add-task.tsx

"use client";

import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { GearIcon } from "@radix-ui/react-icons";
import { useMutation } from "@tanstack/react-query";
import { useTranslate, T } from "@tolgee/react";

import { useChat } from "ai/react";
import axios from "axios";
import { useToast } from "@/hooks/use-toast";
import { useSocket } from "@/providers/socket-provider";
import { Task as TTask } from "@prisma/client";
import { TCreateTaskSchema } from "@/lib/validators/create-task";
import { LoaderCircle } from "lucide-react";

export const AddTask = ({ userId }: { userId: string }) => {
  const [title, setTitle] = useState<string>("");
  const [description, setDescription] = useState<string>("");

  const socket = useSocket();

  const { t } = useTranslate();
  const { toast } = useToast();

  const {
    messages,
    handleSubmit: handleAISubmit,
    setInput: setAIInput,
    isLoading: isAILoading,
  } = useChat();

  useEffect(() => {
    const lastAssistantMessage = messages.findLast(
      (message) => message.role === "assistant",
    )?.content;
    if (lastAssistantMessage && description !== lastAssistantMessage) {
      setDescription(lastAssistantMessage);
    }
  }, [messages, description]);

  const handleGenerateClick = () => {
    setAIInput(title);
    handleAISubmit();
  };

  const { mutate: createTask, isPending } = useMutation({
    mutationFn: async () => {
      const payload: TCreateTaskSchema = {
        title,
        description,
      };
      const { data } = await axios.post(`/api/tasks/${userId}/create`, payload);
      return data as TTask;
    },
    onSuccess: (newTask) => {
      setTitle("");
      setDescription("");

      socket?.emit("task-created", newTask);
    },
    onError: (error) => {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        description: t("failed-to-create-task"),
        variant: "destructive",
      });
    },
  });

  const isSubmitDisabled = isPending || title.length === 0 || isAILoading;

  const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    createTask();
  };

  const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event.target.value);
  };

  return (
    <div className="flex justify-center mt-2">
      <div className="w-full max-w-5xl p-6 bg-white rounded-lg shadow-lg transition-shadow duration-300 ease-in-out hover:shadow-2xl">
        <form onSubmit={handleFormSubmit} className="space-y-4">
          <Input
            autoFocus
            type="text"
            placeholder={t("task-title")}
            value={title}
            onChange={handleTitleChange}
            className="w-full px-4 py-2 rounded"
          />
          <Button
            type="button"
            onClick={handleGenerateClick}
            className="flex items-center gap-2 font-semibold h-10 px-4 text-white rounded w-full sm:w-auto"
            disabled={title.split(" ").length < 3 || isPending || isAILoading}
          >
            {isAILoading ? (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin" />
            ) : (
              <GearIcon className="w-5 h-5 text-gray-300" />
            )}
            <T keyName="generate" />
          </Button>

          <Textarea
            placeholder={t("task-description")}
            value={description}
            // Prevent user input in Textarea
            readOnly
            className="mt-4 w-full h-28 px-4 py-2 border border-gray-300 rounded resize-none"
          />

          <Button
            type="submit"
            className="font-semibold h-10 px-4 text-white rounded w-full sm:w-auto"
            disabled={isSubmitDisabled}
          >
            {isPending && (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin" />
            )}
            <T keyName="submit" />
          </Button>
        </form>
      </div>
    </div>
  );
};

這個元件有很多事情要做。有兩個輸入字段,它們都是受控元件。然而,文字區域被設定為readOnly因為它是由人工智慧而不是用戶填充的。我們使用兩個狀態變數titledescription來管理標題和描述欄位。

當使用者點擊提交按鈕時,會向我們的任務建立端點發出 API 請求,該端點會在資料庫中為使用者新增任務並將其傳回。如果發生任何錯誤,Toast 會顯示翻譯後的錯誤訊息。成功後,我們重置輸入欄位並發出伺服器將拾取的事件,觸發面板元件上的更新以顯示所有任務。

從 Vercel 的 AI SDK 存取的useChat鉤子在這裡特別有趣。它提供對訊息歷史記錄和當前輸入訊息等欄位的存取,以及isPending變數,該變數追蹤 AI 的回應是否仍在載入。

當使用者點擊「生成」按鈕時,我們將標題提交給 AI。收到回應後,我們使用useEffect掛鉤檢查messages欄位。如果助理的訊息更新,我們會為此新訊息設定描述。

更新server.ts文件

現在,我們將更新server.ts檔案以偵聽task-created事件。使用以下程式碼行修改專案根目錄中的server.ts檔案:

// 👇 server.ts

// Rest of the code...

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer);

  io.on("connection", (socket) => {
    console.log(`'${socket.id}' user just connected! ✨`);

    socket.on("task-created", async (payload: TTask) => {
      io.sockets.emit("task-created", payload);
    });

    socket.on("disconnect", () => {
      console.log(`'${socket.id}' user just disconnected! 👀`);
    });
  });

  httpServer
    .once("error", (err) => {
      console.error("ERROR: server failure", err);
      process.exit(1);
    })

    .listen(port, () => {
      console.log(`Listening on '<http://$>{hostname}:${port}'`);
    });
});

在這裡,我們監聽該事件,一旦接收到該事件,我們將其發送到所有連接的套接字。然後它被<Board />元件接收,我們稍後將建立該元件。此元件將負責以看板格式顯示所有任務並使用接收到的資料更新任務。


設定用於 AI 和任務建立的 API 路由

現在,在我們的<AddTask />元件中,當使用者點擊「產生」按鈕時, handleAISubmit函數會使用 POST 請求呼叫/api/chat端點。因此,我們需要建立 API 路由來處理流向描述欄位的回應流。

用於訊息驗證的 Zod 架構

讓我們建立一個模式檔案來驗證使用者和 AI 的輸入。在src/lib/validators目錄中,使用以下程式碼行建立一個新檔案message.ts

// 👇 src/lib/validators/message.ts

import { z } from "zod";

const MessageSchema = z.object({
  role: z.string().min(1),
  content: z.string().min(1),
});

export const ResponseBodySchema = z.object({
  messages: z.array(MessageSchema),
});

export type TResponseBodySchema = z.infer<typeof ResponseBodySchema>;

現在,我們可以使用這些模式來推斷 AI 的回應類型,以在 API 路由中進行類型驗證。

OpenAI 的聊天路線

最後,在src/api/chat目錄中,使用以下程式碼行建立一個新檔案route.ts

// 👇 src/api/chat/route.ts

import { ResponseBodySchema } from "@/lib/validators/message";
import { NextRequest, NextResponse } from "next/server";

import { openai } from "@ai-sdk/openai";
import { streamText, convertToCoreMessages } from "ai";

// Allow streaming responses up to 15 seconds
export const maxDuration = 15;

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const validatedFields = ResponseBodySchema.safeParse(body);

    if (!validatedFields.success) {
      return NextResponse.json(
        {
          errors: validatedFields.error.flatten().fieldErrors,
        },
        { status: 422 },
      );
    }

    const { messages } = validatedFields.data;

    const lastUserMessage = messages.findLast(
      (message) => message.role === "user",
    )?.content;

    if (!lastUserMessage) {
      return NextResponse.json(
        { error: "No user message found" },
        { status: 400 },
      );
    }

    const response = await streamText({
      model: openai("gpt-3.5-turbo"),
      messages: convertToCoreMessages([
        {
          role: "user",
          content: `Generate a short description for a kanban board task with the title: ${lastUserMessage}.
          Make sure to give the response in plain text and not include any markdown characters.`,
        },
      ]),
    });

    return response.toDataStreamResponse();
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Something went wrong" },
      { status: 500 },
    );
  }
}

在此 API 路線中,我們首先驗證輸入,以確保它包含一個messages陣列,其中每個物件都有一個rolecontent欄位。接下來,我們從該陣列中提取最新的用戶訊息(即最近向 AI 提出的問題或請求)。有了這則訊息,我們將其傳遞給streamText函數,提示AI根據訊息內容產生任務描述。

最後,我們將回應作為資料流返回,允許客戶端即時更新訊息陣列。此流響應會觸發useEffect掛鉤,該掛鉤更新description字段,直接在文字區域中顯示 AI 生成的描述。

用於新增任務驗證的 Zod 架構

src/lib/validators目錄中,使用下列程式碼行建立一個新檔案create-task.ts

// 👇 src/lib/validators/create-task.ts

import { z } from "zod";

export const CreateTaskSchema = z.object({
  title: z.string().trim().min(1).max(50),
  description: z.string().trim().optional(),
});

export type TCreateTaskSchema = z.infer<typeof CreateTaskSchema>;

CreateTaskSchema架構定義用於建立任務的結構。它要求title介於 1 到 50 個字元之間,並包含可選的description

推斷類型TCreateTaskSchema為此結構提供了類型安全性,讓我們可以使用它在客戶端和伺服器端程式碼中實現一致的類型。

用於建立任務的 API 端點

現在,讓我們處理任務建立端點,即/api/tasks/[userId]/create

使用此路徑建立一個新目錄,並使用以下程式碼行在該檔案內建立一個route.ts

// 👇 app/api/tasks/[userId]/create/route.ts

import { db } from "@/db";
import { CreateTaskSchema } from "@/lib/validators/create-task";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

export async function POST(
  req: NextRequest,
  { params }: { params: { userId: string } },
) {
  try {
    const session = await getServerSession();

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = await req.json();

    const validatedFields = CreateTaskSchema.safeParse(body);
    if (!validatedFields.success) {
      return NextResponse.json(
        { error: validatedFields.error.flatten().fieldErrors },
        { status: 422 },
      );
    }

    const { title, description } = validatedFields.data;

    const columnTasks = await db.task.findMany({
      where: {
        userId: params.userId,
        column: 0,
      },
    });

    const newOrder = columnTasks.length;

    const newTask = await db.task.create({
      data: {
        title,
        ...(description ? { description } : {}),
        userId: params.userId,
        column: 0,
        order: newOrder,
      },
    });

    return NextResponse.json(newTask);
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 },
    );
  }
}

此 API 路由建立一個新任務。它首先使用getServerSession檢查有效的使用者會話。如果沒有活動會話(使用者未登入),則傳回401 Unauthorized錯誤。接下來,它使用CreateTaskSchema驗證請求正文,如果驗證失敗,它會以422狀態和錯誤詳細資訊回應。

如果輸入有效,它將對預設列( 0 - 正在進行)中的任務進行排序,然後使用提供的標題、可選描述、使用者 ID、列和訂單值(即長度)在資料庫中建立新任務陣列的。成功則返回新任務;否則,它會傳回一個Internal Server Error


建構看板

💡 在這裡,我們將建立主要的 UI 元件和一些用於更新板上任務的 API

闆卡元件

現在,讓我們建立一個<Board />元件,為我們的應用程式呈現多個不同的任務。

src/components目錄中,使用以下程式碼行建立一個新檔案board.tsx


// 👇 src/components/board.tsx

"use client";

import { useSocket } from "@/providers/socket-provider";
import { useEffect, useState } from "react";
import {
  DragDropContext,
  Draggable,
  Droppable,
  DropResult,
} from "react-beautiful-dnd";
import { getSession } from "next-auth/react";
import axios from "axios";
import { Session } from "next-auth";
import { Task as TTask } from "@prisma/client";
import { Task } from "@/components/task";
import { T, useTranslate } from "@tolgee/react";
import { useToast } from "@/hooks/use-toast";

export const Board = ({ userId }: { userId: string }) => {
  const socket = useSocket();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [tasks, setTasks] = useState<TTask[] | null>([]);
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    const fetchSession = async () => {
      try {
        const sessionData = await getSession();
        setSession(sessionData);
      } catch (error) {
        console.error("ERROR:", error);
        toast({
          title: t("something-went-wrong"),
          variant: "destructive",
        });
      }
    };
    fetchSession();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!session) return;

    const fetchUserTasks = async () => {
      try {
        const userEmail = session.user?.email || "";
        const { data } = (await axios.get("/api/tasks", {
          params: { userId, email: userEmail },
        })) as { data: { tasks: TTask[] } };

        setTasks(data.tasks);
      } catch (error) {
        console.error("ERROR:", error);
      }
    };
    fetchUserTasks();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session]);

  useEffect(() => {
    const handleTasksUpdated = (data: TTask[

共有 0 則留言