发布于 2026-01-06 0 阅读
0

使用 Next.js、Vercel AI 和 Tolgee 构建看板

使用 Next.js、Vercel AI 和 Tolgee 构建看板

太长不看

在本文中,我们将使用 WebSockets 在 Next.js 中构建一个实时看板,并提供数据库支持、通过 Vercel AI SDK 实现的 AI 支持以及通过 Tolgee 实现的本地化支持。

你将学到:✨

给 Tolgee 仓库点赞 ⭐

你准备好打造一款支持人工智能和本地化功能的独特看板了吗?🔥

准备好的 GIF


项目设置🛠️

初始化 Next.js 应用程序

使用以下命令初始化一个新的 Next.js 应用程序:

ℹ️ 你可以使用任何你喜欢的包管理器。在这个项目中,我将使用 npm。

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

Enter fullscreen mode Exit fullscreen mode

接下来,进入新创建的 Next.js 项目:

cd kanban-ai-realtime-localization

Enter fullscreen mode Exit fullscreen mode

安装依赖项

我们需要一些依赖项。运行以下命令安装项目所需的所有依赖项:

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

Enter fullscreen mode Exit fullscreen mode

设置 UI 组件

对于 UI 组件,我们将使用shadcn/ui。使用以下命令以默认设置初始化它:

npx shadcn@latest init -d

Enter fullscreen mode Exit fullscreen mode

现在,让我们添加一些稍后将在应用程序中使用的 UI 组件。要从 .js 添加可重用组件shadcn/ui,请运行以下命令:

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

Enter fullscreen mode Exit fullscreen mode

该目录下app/components/ui将添加一些用于这些组件的附加文件,我们在构建应用程序的用户界面时将使用这些文件。


建立数据库模型📦

初始化 Prisma

使用以下命令初始化 Prisma:

npx prisma init

Enter fullscreen mode Exit fullscreen mode

运行此命令后,应在项目根目录schema.prisma下创建一个新文件。prisma

定义 Prisma 模式

修改新创建的schema.prisma文件,使其使用 PostgreSQL 作为数据库,并包含 User 和 Task 模型。

// 👇 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
}

Enter fullscreen mode Exit fullscreen mode

该模型非常简单:每个用户可以拥有多个任务,每个任务都与特定用户关联。任务column状态用一个整数值表示(0 表示进行中,1 表示待处理,2 表示已完成)。该order值决定了每个任务在其所属列中的位置。

现在我们的模型已经准备就绪,需要将其上传到数据库。为此,我们需要连接 URL。

如果您已经可以通过 Neon 或其他服务访问数据库,那就太好了。只需.env在文件中填写连接 URL 即可。您无需使用 Docker 在本地设置数据库。


使用 Docker 在本地搭建数据库 🐳

如果您正在跟着教程操作,并且只想使用 Docker 在本地 PostgreSQL 数据库上尝试该项目,请DATABASE_URL.env文件中添加一个名为 `<connection string value>` 的新变量。

// 👇 .env

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

Enter fullscreen mode Exit fullscreen mode

要在本地运行数据库,请确保已安装 Docker。scripts在项目根目录下创建一个名为 `<directory_name>` 的新目录,并在其中添加一个名为 `<file_name>` 的文件,并添加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"

Enter fullscreen mode Exit fullscreen mode

该脚本的基本功能是读取.env文件中的DATABASE_URL变量,提取所有相关数据,例如用户名、密码、数据库名称等,并在容器不存在时创建容器;如果容器已存在,则直接启动该容器。

运行此脚本以创建并运行一个 PostgreSQL 容器,该容器将托管我们应用程序的所有用户数据。

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

Enter fullscreen mode Exit fullscreen mode

现在,我们应该已经有一个运行着 PostgreSQL 的容器了。您可以通过运行以下命令来检查是否如此:

docker ps | grep "kanban-board"

Enter fullscreen mode Exit fullscreen mode

现在,我们需要一种方法来实例化 Prisma 客户端以与数据库进行交互。

index.ts在目录下创建一个新文件src/db,并添加以下代码:

// 👇 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;

Enter fullscreen mode Exit fullscreen mode

我们设置了单例实例,PrismaClient以确保在整个应用程序中只创建和重用一个实例,这在开发模式下尤其有用。

现在我们可以使用导出的常量db在应用程序中与数据库进行交互。

运行以下命令,将您对架构的更改推送到数据库。

npx prisma db push

Enter fullscreen mode Exit fullscreen mode

现在,为了使更新后的类型在 IDE 中生效,请运行以下命令,根据我们更新后的架构生成新类型。

npx prisma generate

Enter fullscreen mode Exit fullscreen mode

这就是我们搭建应用程序数据库所需的一切。🥳


为 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;
}

Enter fullscreen mode Exit fullscreen mode

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",
  });
}

Enter fullscreen mode Exit fullscreen mode

getStaticData函数负责加载特定语言和命名空间的翻译,以预取本地化内容。它会按messages语言和命名空间从目录中获取 JSON 文件,然后将所有内容打包到一个对象中并返回。

我们的应用程序将提供四种语言选择(英语、捷克语、法语和德语)。如有需要,您可以添加对其他语言的支持。

messages在项目根目录下,我们将为不同的单词和句子存储不同的静态数据。

ℹ️ 你可以在我的仓库里找到这些静态翻译文件的链接。这个文件里没有什么需要解释的,因为它们只是一堆不同语言的翻译句子。

TolgeeBase函数为 Tolgee 配置翻译处理工具,并添加了对 ICU 消息格式的支持FormatIcu,同时包含DevTools调试功能。该函数使用环境变量中的 API 密钥和 URL,并将英语设置en为备用语言。

  1. 更新环境变量

我们使用了两个不同的环境变量,请将.env这些 API 密钥填充到文件中。注册 Tolgee 帐户即可访问 API 密钥TOLGEE_API_KEYS,但对于此应用程序而言,并非必须拥有该 API 密钥。

// 👇 .env

TOLGEE_API_URL=https://app.tolgee.io

# Optional
TOLGEE_API_KEY=

Enter fullscreen mode Exit fullscreen mode
  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 } }),
    }),
});

Enter fullscreen mode Exit fullscreen mode

这段代码创建了一个 Tolgee 实例,用于服务器端翻译处理。它首先设置getLocale使用 `get_username_language() getLanguage` 函数来获取用户的首选语言。然后,在 `tolgee.get_translation()` 函数中createTolgee,它使用 `get_username_language()` 函数为所有支持的语言初始化翻译数据getStaticData

它还将 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>
  );
};

Enter fullscreen mode Exit fullscreen mode

这段代码设置了一个客户端 Tolgee 翻译提供程序。TolgeeProviderClient它接受language`<language> ` staticData、`<data>` 和 ` children<language>` 作为 props,并使用指定的语言和数据初始化 Tolgee。在 `<language>` 内部useEffect,它监听语言变化,并在语言更新时permanentChange刷新页面。router.refresh()

最后,TolgeeProvider渲染子元素,使用ssr预加载翻译的选项,如果翻译没有立即准备就绪,则显示“正在加载...”。

  1. 将应用程序包装TolgeeProviderClientlayout.tsx

最后,用该<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>
  );
}

Enter fullscreen mode Exit fullscreen mode

首先,我们根据请求头或通过函数设置的 cookie 获取用户的语言环境。然后,我们将该语言环境提供给标签<html />

这就是在 Next.js 应用中配置 Tolgee 的全部步骤。✨这将是所有 Next.js 应用中使用 Tolgee 实现位置信息的标准流程。


设置身份验证🛡️

我们将使用 NextAuth 进行应用程序身份验证。首先,让我们定义一个新的 Zod schema,用于验证用户传递的数据。

Zod 模式用于验证

定义一个 Zod schema(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>;

Enter fullscreen mode Exit fullscreen mode

我们要求邮箱地址必须完全一致,不能是其他任何字符串;密码字段的长度必须至少为 8 个字符,最多为 20 个字符。我们将在登录/注册表单的多个位置使用此验证方案,以验证用户输入的数据是否符合要求。

NextAuth 配置

route.ts您可以在`<path>` 下设置 NextAuth src/app/api/auth/[...nextauth],并使用CredentialsProvider`<interface>` 进行身份验证。该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: "example@gmail.com",
        },
        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 };

Enter fullscreen mode Exit fullscreen mode

authorize函数逻辑负责判断用户是否能够登录。在此设置中,该函数会检查提供的电子邮件地址和密码是否与数据库中已存在的用户匹配。

我们仅使用基于凭据的身份验证。首先,它使用AuthSchema字段验证来验证凭据。如果验证成功,它会根据电子邮件地址在数据库中查找用户。如果找到用户,它会将数据库中哈希后的密码与用户输入的密码进行比较。如果两项检查都通过,则返回用户数据(不包括密码)。

正如您可能已经猜到的,这里我们需要在文件NEXTAUTH_SECRET中定义变量.env。请将.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=

Enter fullscreen mode Exit fullscreen mode

用户注册 API

在此过程中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 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

在这里,我们解析从客户端接收的数据,并使用AuthSchema之前编写的验证程序对其进行验证。然后,我们创建一个旋转值为 12 的哈希值。这将生成一个加密文本,我们将把该文本存储在数据库中,最后,我们返回用户信息。

为了使我们的应用程序更加稳定,让我们添加一个中间件,该中间件会在用户访问特定路由时检查 userSession,如果用户未通过身份验证,则不允许他们访问该路由。

用于路由保护的中间件

我们添加了一个中间件,以限制/kanban未经身份验证的用户对该路由的访问。

// 👇 src/middleware.ts

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

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

Enter fullscreen mode Exit fullscreen mode

这里我们说的是,如果用户没有通过身份验证,就不应该能够访问“/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>
  );
};

Enter fullscreen mode Exit fullscreen mode

这个组件的功能应该很容易理解。我们使用<Select />shadcn/ui 提供的组件来映射所有可用的语言选项。根据用户的选择,我们setLanguage使用之前在文件中编写的函数来设置语言language.ts

💡 注意:请注意,我们没有在代码中硬编码任何文本;而是使用了 Tolgee 的组件来渲染文本。这样,当用户切换语言时,文本也会相应更改。如果我们硬编码文本,翻译功能将无法有效实现。我们将继续采用这种方法。

我们使用Tolgee提供的<T />组件和钩子函数来实现翻译。要了解它们之间的区别,请访问此处tuseTranslate

注销按钮组件

同样地,在该组件目录下创建一个名为 `<filename>` 的新文件,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>
  );
};

Enter fullscreen mode Exit fullscreen mode

与之前类似,当用户点击按钮时,我们会触发该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>
  );
};

Enter fullscreen mode Exit fullscreen mode

Navbar组件为我们的应用程序创建一个导航栏。它会检查用户是否已登录getServerSession。如果用户已通过身份验证,则会显示一个注销按钮。否则,它会显示一个链接,供用户登录和注册。


构建身份验证页面

现在,我们已经完成了后端身份验证逻辑的处理,并且也完成了 Tolgee 在我们的应用程序中的集成。接下来,让我们来编写一些客户端逻辑并构建一些用户界面。

登录组件

在该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>
  );
};

Enter fullscreen mode Exit fullscreen mode

Login组件显示一个登录表单,用于输入电子邮件和密码,这两个输入字段均为受控组件。表单提交后,它会调用相应的组件signInnext-auth处理身份验证。如果登录失败,则会通过 Toast 通知显示翻译后的错误信息。登录成功后,用户将被重定向到首页。

我们还有一个单独的加载状态变量,用于在用户登录我们的应用程序时显示加载动画图标。

目前,这只是我们创建的一个组件;它还没有显示在我们的应用程序中。为此,我们需要将此组件渲染到app应用程序的目录中。

登录页面路由

在目录下src/app/login,创建一个名为 `<filename>` 的新文件,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 />;
}

Enter fullscreen mode Exit fullscreen mode

在登录页面,我们首先检查用户是否拥有有效会话。如果用户拥有有效会话,则直接将其重定向到“/kanban”路由(我们稍后会实现)。如果用户没有有效会话,则显示<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>
  );
};

Enter fullscreen mode Exit fullscreen mode

此组件中的邮箱和密码输入框作为受控组件运行,类似于登录页面上的组件。这里,我们使用 React Query 来简化 POST 请求的发送过程。这种方法无需管理单独的加载或错误处理状态。

当用户点击表单中的提交按钮时,系统会向我们的 API 路由发送一个 POST 请求,用于在之前开发的数据库中注册用户。如果注册成功,用户将被重定向到登录页面。如果注册失败,则会显示一个包含翻译后错误信息的提示框。

当用户点击提交按钮时,系统会向我们的 API 路由发送一个 POST 请求,将用户注册到我们之前设置的数据库中。注册成功后,用户将被重定向到登录页面。如果注册失败,我们会显示一个带有翻译后的错误信息的提示框,该信息会使用相关的键值进行匹配。

注册页面路由

在目录下src/app/register,创建一个名为 `<filename>` 的新文件,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 />;
}

Enter fullscreen mode Exit fullscreen mode

完成此页面设置后,我们已完成应用程序的身份验证流程。现在,您应该拥有一个支持本地化且已启用身份验证的应用程序。


设置 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;
};

Enter fullscreen mode Exit fullscreen mode

这段代码定义了一个函数,该函数getSocket会初始化一个Socket.IO客户端连接到环境变量中指定的 URL NEXT_PUBLIC_APP_URL,并确保套接字只创建一次。如果套接字已经初始化,则直接返回现有的套接字实例。

套接字提供程序

现在,我们需要管理socket.io连接,并为组件提供访问 socket 实例的方法。在该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>
  );
}

Enter fullscreen mode Exit fullscreen mode

这段代码创建了一个用于管理Socket.IO连接的 React 上下文,并提供了一个useSocket访问套接字实例的钩子。它SocketProviderClient使用初始化函数初始化套接字getSocket并建立连接,然后将套接字的子组件包裹在一个上下文提供程序中,以便在整个应用程序中共享该套接字实例。

现在,我们需要用这个套接字提供程序包装我们的应用程序,以便能够使用 WebSocket 发送和接收数据。

QueryClient 和 SocketProvider

在同一目录下,创建一个新文件,我们将使用该文件providers.tsx来包装我们的子组件,并使用我们新创建的QueryClientProvider@tanstack/react-querySocketProviderClient

将以下代码添加到文件中:

// 👇 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;

Enter fullscreen mode Exit fullscreen mode

现在,我们只需要用这个<Providers />组件包裹我们的应用程序,这样就可以访问我们的应用程序套接字和 react-query 支持。

使用提供程序包装应用程序布局

在项目根目录中,修改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>
  );
}

Enter fullscreen mode Exit fullscreen mode

使用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}'`);
    });
});

Enter fullscreen mode Exit fullscreen mode

现在,这个文件就成了我们应用程序的入口点。我们可以用它完成几乎所有使用socket.io服务器和像 express.js 这样的后端框架server.ts能做的事情。

现在我们可以监听类似于此处监听“连接”和“断开连接”事件的任何事件。将来我们会修改此文件以监听我们自定义的事件。

TypeScript 服务器配置

现在,创建一个新文件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"]
  }

Enter fullscreen mode Exit fullscreen mode

tsconfig.server.json文件扩展了 TypeScript 的基础配置tsconfig.json,并为我们的项目指定了一些自定义设置。它使用 CommonJS 进行模块输出,并将编译后的文件定向到指定dist目录。该isolatedModules选项设置为falsetrue,允许生成非自包含的文件;而noEmit设置为 false false,允许生成输出文件。最后,它仅将指定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...

Enter fullscreen mode Exit fullscreen mode

此外,我们还需要调整nodemon配置,以便监视server.ts文件中的变化并更改其执行命令。

Nodemon 配置

nodemon.json在项目根目录下创建一个新文件,并添加以下配置:

// 👇 nodemon.json

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

Enter fullscreen mode Exit fullscreen mode

设置看板

最后,我们已经完成了看板的所有准备工作。接下来,让我们开始展示看板并创建任务。

任务组件

在该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>
  );
};

Enter fullscreen mode Exit fullscreen mode

我们将使用它在应用程序中显示任务。这里,我们本质上是将任务对象作为 prop 接收,并使用 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>
  );
};

Enter fullscreen mode Exit fullscreen mode

This component has a lot going on. There are two input fields, both of which are controlled components. However, the textarea is set to readOnly since it’s meant to be populated by the AI rather than by the user. We use two state variables, title, and description, to manage the title and description fields.

When the user clicks the submit button, an API request is made to our task creation endpoint, which adds a new task for the user in the database and returns it. If any errors occur, a toast displays the translated error message. Upon success, we reset the input fields and emit an event that the server will pick up, triggering an update on the board component to display all tasks.

The useChat hook, accessed from Vercel's AI SDK, is particularly interesting here. It provides access to fields like the message history and the current input message, along with the isPending variable, which tracks whether the AI’s response is still loading.

When the user clicks the Generate button, we submit the title to the AI. Once we receive a response, we check the messages field using the useEffect hook. If the assistant’s message updates, we set the description to this new message.

Update the server.ts file

Now, we will update the server.ts file to also listen for the task-created event. Modify the server.ts file in the root of the project with the following lines of code:

// 👇 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}'`);
    });
});

Enter fullscreen mode Exit fullscreen mode

Here, we listen for that event, and once it is received, we emit it to all the connected sockets. It is then received by the <Board /> component, which we will create in a moment. This component will be responsible for displaying all the tasks in a board format and updating the tasks with the received data.


Setup API Routes for AI and Task creation

Now, in our <AddTask /> component, when the user clicks on the Generate button, the handleAISubmit function makes a call to /api/chat endpoint with a POST request. So, we need to create that API route for handling response streaming to our description field.

Zod Schema for Message Validation

Let’s create a schema file for validation of the input from the user and the AI. Inside the src/lib/validators directory, create a new file message.ts with the following lines of code:

// 👇 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>;

Enter fullscreen mode Exit fullscreen mode

Now, we can use these schemas to infer the type of response from the AI to get type validation in our API route.

Chat Route for OpenAI

Finally, Inside the src/api/chat directory, create a new file route.ts with the following lines of code:

// 👇 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 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

In this API route, we start by validating the input to ensure it includes a messages array where each object has a role and content field. Next, we extract the latest user message (i.e., the most recent question or request to the AI) from this array. With this message in hand, we pass it to the streamText function, prompting the AI to generate a task description based on the message content.

Finally, we return the response as a data stream, allowing the client to update the messages array in real-time. This streaming response triggers the useEffect hook, which updates the description field, displaying the AI-generated description directly in the text area.

Zod Schema for Add Task Validation

Inside the src/lib/validators directory, create a new file create-task.ts with the following lines of code:

// 👇 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>;

Enter fullscreen mode Exit fullscreen mode

The CreateTaskSchema schema defines the structure for creating a task. It requires a title between 1 and 50 characters and includes an optional description.

The inferred type, TCreateTaskSchema, provides type safety for this structure, allowing us to use it for consistent typing in both client-side and server-side code.

API Endpoint for Creating a Task

Now, let’s work on the task creation endpoint, i.e. /api/tasks/[userId]/create.

Create a new directory with this path and create a route.ts inside the file with the following lines of code:

// 👇 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 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

This API route creates a new task. It first checks for a valid user session with getServerSession. If there is no active session (the user is not logged in), it returns a 401 Unauthorized error. Next, it validates the request body with CreateTaskSchema, and if validation fails, it responds with a 422 status and error details.

If the input is valid, it counts tasks in the default column (0 - ongoing) for ordering, then creates a new task in the database with the provided title, optional description, user ID, column, and order value, which is the length of the array. The new task is returned on success; otherwise, it returns an Internal Server Error.


Build the Kanban Board

💡 Here, we will build the main UI components and some APIs for updating tasks on our Board

Board Component

Now, let’s create a <Board /> component that renders multiple different tasks for our application.

Inside the src/components directory, create a new file board.tsx with the following lines of code:

// 👇 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[] | undefined) => {
      if (!data) return;
      setTasks(data);
    };

    const handleTaskCreated = (newTask: TTask) => {
      setTasks((prevTasks) => [...(prevTasks || []), newTask]);
    };

    socket?.on("tasks-updated", handleTasksUpdated);
    socket?.on("task-created", handleTaskCreated);

    return () => {
      socket?.off("tasks-updated", handleTasksUpdated);
      socket?.off("task-created", handleTaskCreated);
    };
  }, [socket]);

  const tasksByStatus = (status: number) =>
    tasks?.filter((task) => task.column === status);

  const columns = {
    0: "Ongoing",
    1: "Pending",
    2: "Completed",
  };

  const handleDragEnd = ({ destination, source }: DropResult) => {
    if (!destination) return;
    if (
      destination.index === source.index &&
      destination.droppableId === source.droppableId
    )
      return;

    socket?.emit("task-drag", {
      source,
      destination,
      email: session?.user?.email || "",
    });
  };

  if (!tasks || tasks.length === 0) return null;

  return (
    <div className="container mx-auto mt-10 mb-5">
      <DragDropContext onDragEnd={handleDragEnd}>
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
          {Object.entries(columns).map(([status, title]) => (
            <div
              key={status}
              className="p-4 border rounded-lg shadow-lg bg-gray-50 flex flex-col items-center"
            >
              <h2 className="text-xl font-bold mb-4 text-center">
                <T keyName={title.toLowerCase()} />
              </h2>
              <Droppable droppableId={status}>
                {(provided) => (
                  <div
                    {...provided.droppableProps}
                    ref={provided.innerRef}
                    className="w-full flex flex-col items-center min-h-40"
                  >
                    {tasksByStatus(Number(status))?.map((task, index) => (
                      <Draggable
                        key={task.id}
                        draggableId={task.id}
                        index={index}
                      >
                        {(provided) => (
                          <div
                            ref={provided.innerRef}
                            {...provided.draggableProps}
                            {...provided.dragHandleProps}
                            className="w-full"
                          >
                            <Task task={task} />
                          </div>
                        )}
                      </Draggable>
                    ))}
                    {provided.placeholder}
                  </div>
                )}
              </Droppable>
            </div>
          ))}
        </div>
      </DragDropContext>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

This is the component where we will be utilizing the main feature of a Kanban board, i.e., drag-and-drop items. For this, we will use our previously installed package, react-beautiful-dnd. The component first fetches the user session using getSession and sets it in the state. Once the session is available, it makes an API call to fetch tasks for the logged-in user and stores them in tasks.

It listens for two-socket events —tasks-updated, which updates the task list, and task-created, which appends a new task to the current task list.

Tasks are grouped by column status (0 for "Ongoing," 1 for "Pending," and 2 for "Completed") using the tasksByStatus function. The component maps these statuses to render each column with the corresponding tasks.

The DragDropContext wrapper enables drag-and-drop functionality. When a task is moved, handleDragEnd sends the new task order to the server via a socket event for syncing.

Each column is a Droppable area that contains draggable Task components, allowing users to reorder tasks within and across columns.

API Route to fetch User Tasks

Now, let’s work on the /api/tasks route which is responsible for returning a list of user tasks from the database.

Inside the app/api/tasks, create a route.ts file with the following lines of code:

// 👇 src/app/api/tasks/routs.ts

import { db } from "@/db";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession();

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

    const url = new URL(req.url);
    const passedEmail = url.searchParams.get("email") || "";
    const userId = url.searchParams.get("userId");

    if (!userId || session.user?.email !== passedEmail) {
      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
    }

    const userWithBoardsAndTasks = await db.user.findUnique({
      where: { email: passedEmail, id: userId },
      select: {
        id: true,
        password: false,
        tasks: true,
      },
    });

    if (!userWithBoardsAndTasks) {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

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

Enter fullscreen mode Exit fullscreen mode

The GET function in this API route fetches a user's information, including their tasks. It starts by validating the authentication using getServerSession. If the session is absent, a 401 Unauthorized status is returned.

The route extracts the email and userId from the request URL's query parameters. If the userId is missing or the session's user email does not match the provided email, a 403 Forbidden status is returned.

Next, it queries the database for a user with the specified email and ID, selecting only the user's ID and tasks. If no user is found, a 404 Not Found status is returned. If the user exists, their data is sent in the response.

Now, we are almost done; we just need to listen for the task-drag event from the <Board /> component inside the server.ts file and handle it accordingly.

Update the server.ts file

Modify the server.ts file in the root of the project with the following lines of code:

// 👇 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("task-created", async (payload: TTask) => {
      io.sockets.emit("task-created", payload);
    });

    socket.on(
      "task-drag",
      async (payload: {
        source: DraggableLocation;
        destination: DraggableLocation;
        email: string;
      }) => {
        const { source, destination, email } = payload;

        try {
          const updatedTasks = await handleTaskDrag(email, source, destination);
          if (updatedTasks) {
            io.sockets.emit("tasks-updated", updatedTasks);
          }
        } catch (error) {
          console.error(
            "ERROR: failed to update or fetch the user tasks",
            error,
          );
        }
      },
    );

    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}'`);
    });
});

async function handleTaskDrag(
  email: string,
  source: DraggableLocation,
  destination: DraggableLocation,
) {
  const dbUser = await db.user.findUnique({ where: { email } });
  if (!dbUser) return;

  const tasks = await db.task.findMany({ where: { userId: dbUser.id } });
  return updateTasksInDB(tasks, source, destination);
}

async function updateTasksInDB(
  tasks: TTask[],
  source: DraggableLocation,
  destination: DraggableLocation,
) {
  const { droppableId: sourceColumn, index: sourceOrder } = source;
  const { droppableId: destinationColumn, index: destinationOrder } =
    destination;

  const taskMoved = tasks.find(
    (task) =>
      task.column === Number(sourceColumn) && task.order === sourceOrder,
  );

  if (!taskMoved) return;

  // Filter the moved task from the tasks array.
  tasks = tasks.filter((task) => task.id !== taskMoved.id);

  taskMoved.column = Number(destinationColumn);
  taskMoved.order = destinationOrder;

  const columns: { [key: number]: TTask[] } = {
    0: tasks.filter((task) => task.column === 0),
    1: tasks.filter((task) => task.column === 1),
    2: tasks.filter((task) => task.column === 2),
  };

  columns[taskMoved.column].splice(destinationOrder, 0, taskMoved);

  // Reorder each column to have sequential order values
  Object.values(columns).forEach((columnTasks) => {
    columnTasks.forEach((task, index) => {
      task.order = index;
    });
  });

  tasks = [...columns[0], ...columns[1], ...columns[2]];

  // Sort tasks by column and order before returning
  tasks.sort((a, b) =>
    a.column === b.column ? a.order - b.order : a.column - b.column,
  );

  const updateTasksPromises = tasks.map((task) =>
    db.task.update({
      where: {
        id: task.id,
      },
      data: {
        column: task.column,
        order: task.order,
      },
    }),
  );

  // Execute all updates in parallel
  await Promise.all(updateTasksPromises);

  return tasks;
}

Enter fullscreen mode Exit fullscreen mode

The task-drag event is responsible for handling the drag-and-drop functionality of tasks within your Kanban board. When a task is dragged from one position to another, this event is triggered, allowing the server to update the task's status and position in the database.

When a client emits the 'task-drag' event, it sends a payload containing the source and destination locations of the task being dragged, as well as the user's email address. The server listens for this event.

The server then calls the handleTaskDrag function, passing the user's email, the source, and the destination as arguments. This function is responsible for fetching the user from the database using their email address, ensuring that the task updates are associated with the correct user.

Within handleTaskDrag, the function retrieves the user's tasks from the database and then calls updateTasksInDB, which processes the task update logic. This function updates the column and order of the task based on the drag-and-drop operation, ensuring that the tasks are rearranged correctly in the database.

If the tasks are updated successfully, the updated tasks are emitted back to all connected clients using io.sockets.emit, broadcasting the changes so that the user interface can be updated in real time.

Now that we have both the <AddTask /> and the <Board /> components ready, it's time to use them inside our application.

Kanban Page Route

在该src/app/kanban目录下,创建一个新文件,page.tsx并添加以下代码:

// 👇 src/app/kanban/page.tsx

import { AddTask } from "@/components/add-task";
import { Board } from "@/components/board";
import { db } from "@/db";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

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

  const dbUser = await db.user.findUnique({
    where: { email: session.user?.email || "" },
    select: { id: true },
  });

  if (!dbUser) redirect("/register");

  const userId = dbUser.id;

  return (
    <>
      <AddTask userId={userId} />
      <Board userId={userId} />
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

它首先检查用户的会话getServerSession,如果会话不存在,则重定向到登录页面。这条语句可能永远不会执行,因为我们middleware.ts之前在src目录中创建了一个文件,其中声明任何以开头的路由/kanban都不能被未经身份验证的用户访问。

不过,多加一层验证总是有益无害的,因为 Next.js 会自动去重任何类似的重复请求。确认会话后,它会从数据库中检索用户 ID;如果找不到该用户,则会重定向到注册页面。

最后,它渲染 `<head>`AddTask和 ` Board<body>` 组件,并将用户的 ID 作为 prop 传递。

最后还有一件事:如果您注意到了,在<Task />前面的组件中,我们提供了一种方法,让用户可以通过链接查看描述/kanban/[taskId]

看板描述页面路线

在该src/app/kanban/[taskId]目录下,创建一个新文件,page.tsx并添加以下代码:

// 👇 src/app/kanban/[taskId]/page.tsx

import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db } from "@/db";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslate, T } from "@/tolgee/server";
import { getServerSession } from "next-auth";

export default async function Page({ params }: { params: { taskId: string } }) {
  const session = await getServerSession();
  if (!session) redirect("/login");

  const task = await db.task.findUnique({
    where: { id: params.taskId },
  });

  if (!task) redirect("/kanban");

  const t = await getTranslate();

  return (
    <div className="flex items-center justify-center px-4 mt-20">
      <div className="w-full max-w-lg space-y-4">
        <Link
          href={"/kanban"}
          className={buttonVariants({
            variant: "secondary",
            size: "lg",
          })}
        >
          <ArrowLeft className="mr-2 h-4 w-4" />
          <T keyName="back" />
        </Link>

        <Card className="p-6 bg-white shadow-lg rounded-lg border border-gray-200">
          <CardHeader>
            <CardTitle className="text-2xl font-bold text-gray-800">
              {task.title}
            </CardTitle>
          </CardHeader>

          <CardContent>
            <p className="text-gray-700 text-lg leading-relaxed">
              {task.description || t("no-description-provided-for-this-task")}
            </p>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

这里的情况也一样:我们首先要验证会话。如前所述,由于我们已经部署了中间件,因此这一步永远不应该执行。

然后,我们只需使用接收到的 prop 参数从数据库中获取任务即可taskId。如果任务不存在,则将用户重定向到相应/kanban页面。如果任务存在,则显示任务的标题和描述。

首页路线

最后,我们来处理应用程序的根首页(/路由)。修改src/app/page.tsx以下代码:

import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

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

  redirect("/login");
}

Enter fullscreen mode Exit fullscreen mode

这里,我们简单地检查用户是否已通过身份验证。如果已通过,则将其发送到相应/kanban路由;否则,将其重定向到登录页面。

这就是我们让看板完美运行所需的全部步骤。现在,您应该拥有一个功能齐全的看板,具备身份验证、本地化和实时支持功能。🥳

屏幕截图


结论⚡

哇!😮‍💨 我们今天一起完成了好多事情。

如果你已经读到这里,说明你已经成功地从零开始,借助一篇博客文章,构建了一个由人工智能和本地化驱动的看板。给自己一个大大的赞吧!

给 Tolgee 仓库点赞 ⭐

关注 Tolgee,获取更多类似内容。

请在下方评论区分享您的想法!👇

非常感谢您的阅读!🎉🫡

谢谢 GIF


文章来源:https://dev.to/tolgee_i18n/building-a-kanban-board-with-nextjsvercel-ai-and-tolgee-493g