使用 Next.js 和 TailwindCSS 构建 Slack 克隆 - 第一部分
协作是成功的关键,而创建一款帮助团队更好地工作的工具可以既有趣又有益。随着越来越多的人在家工作,开发一款帮助所有人通过消息、视频和群聊保持联系的应用可以带来巨大的改变。
在这个由三部分组成的系列中,我们将构建一个 Slack 克隆版 - 一款帮助团队通过即时消息、视频通话和频道保持联系的应用程序。我们将使用 React (Next.js)、TailwindCSS、Prisma 和 Stream SDK 构建此应用程序。
在第一部分中,我们将通过设置项目和构建第一个用户界面(包括频道页面)来设置基础知识。
在第二部分中,我们将使用 Stream React Chat SDK 添加实时消息和频道。最后,在第三部分中,我们将使用 Stream React Video and Audio SDK 添加视频通话(如 Slack Huddles)并添加最后的润色。
在本系列结束时,您将构建一个强大的协作应用程序,该应用程序反映了 Slack 的基本功能。
最终产品的外观如下:
您可以查看现场演示并在 GitHub 上访问完整的源代码。
让我们开始吧!
先决条件
在开始项目之前,请确保您已准备好以下内容:
项目设置
让我们从设置项目开始。我们将首先克隆一个包含初始设置代码和文件夹结构的入门模板,以帮助我们快速入门:
# Clone the repository git clone https://github.com/TropicolX/slack-clone.git # Navigate into the project directory cd slack-clone # Check out the starter branch git checkout starter # Install the dependencies npm install
项目结构应如下所示:

该项目的组织方式是为了保持代码整洁,并且随着代码的增长易于管理:
设置数据库
要构建类似于 Slack 的应用程序,我们需要能够在数据库中存储有关工作区、频道、成员和邀请的信息。
我们将使用 Prisma 来帮助我们轻松地与该数据库交互。
什么是 Prisma?
Prisma 是一个开源 ORM(对象关系映射)工具,它允许我们定义数据库结构并高效地运行查询。使用 Prisma,您可以更直观地编写数据库操作,而无需直接处理 SQL,这让事情变得更简单,并减少错误。
安装 Prisma
让我们首先安装 Prisma 及其依赖项:
npm install prisma --save-dev npm install @prisma/client sqlite3
`@prisma/client` 库帮助我们与数据库交互,而 `sqlite3` 是我们将用于该项目的数据库。
安装完成后,让我们使用以下命令初始化 Prisma:
npx prisma init
此命令设置默认的 Prisma 结构并创建一个新的 `.env` 文件,我们将在其中配置数据库连接。
设置数据库模式
现在,让我们定义数据库模式。打开“prisma/schema.prisma”文件并添加以下内容:
datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model Workspace { id String @id @default(cuid()) name String image String? ownerId String channels Channel[] memberships Membership[] invitations Invitation[] } model Channel { id String @id @default(cuid()) name String description String? workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id]) } model Membership { id String @id @default(cuid()) userId String email String workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id]) role String? @default("member") joinedAt DateTime? @default(now()) @@unique([userId, workspaceId]) } model Invitation { id Int @id @default(autoincrement()) email String token String @unique workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id]) invitedById String acceptedById String? createdAt DateTime @default(now()) acceptedAt DateTime? }
此模式定义了我们的 Slack 克隆中的主要关系。以下是每个模型的作用:
每个模型都有自己的细节和联系,使得我们在构建特征时可以轻松获取相关数据。
接下来,让我们设置数据库连接。导航到你的 `.env` 文件并添加以下内容:
DATABASE_URL=file:./dev.db
这将 SQLite 设置为我们用于本地开发的数据库。您可以在生产中切换到其他数据库,但 SQLite 非常适合快速原型设计和开发。
运行 Prisma 迁移
要根据我们的模式创建数据库表,请运行以下命令:
npx prisma migrate dev --name init
此命令为我们在数据库中定义的模型设置表。它还可以帮助我们在开发过程中跟踪数据库设置的变化。
运行迁移后,通过运行以下命令生成 Prisma 客户端:
npx prisma generate
此命令创建 Prisma 客户端,让我们能够在整个代码中安全可靠地使用数据库。
在代码中设置 Prisma 客户端
要在我们的项目中使用 Prisma 客户端,请在“lib”目录中创建一个新的“prisma.ts”文件,其中包含以下代码:
import { PrismaClient } from '@prisma/client'; let prisma: PrismaClient; if (process.env.NODE_ENV === 'production') { prisma = new PrismaClient(); } else { // @ts-expect-error global.prisma is used by @prisma/client if (!global.prisma) { // @ts-expect-error global.prisma is used by @prisma/client global.prisma = new PrismaClient(); } // @ts-expect-error global.prisma is used by @prisma/client prisma = global.prisma; } export default prisma;
此脚本确保我们只创建一个 Prisma 客户端实例。我们在开发过程中使用全局实例,以避免数据库连接过多的问题。这特别有用,因为频繁重启或热重载可能会导致连接问题。
使用 Clerk 进行用户身份验证
什么是 Clerk?
Clerk 是一个通过提供身份验证和用户资料工具来帮助管理用户的平台。它包括现成的 UI 组件、API 和管理员仪表板,使向应用添加身份验证功能变得更加简单。您无需自己构建整个身份验证系统,Clerk 提供这些现成的功能,从而节省了时间和精力。
在这个项目中,我们将使用 Clerk 来处理用户身份验证。
设置职员帐户

首先,您需要创建一个免费的 Clerk 帐户。转到 Clerk 注册页面,使用您的电子邮件或社交登录选项进行注册。
创建 Clerk 项目

登录后,您可以在 Clerk 中为您的应用创建一个新项目:

创建项目后,您将看到应用程序概览页面,其中包含您的**可发布密钥**和**密钥**——请保留这些信息,因为您稍后会需要它们。

接下来,我们将在注册时将名字和姓氏设为必填字段:
在你的项目中安装 Clerk
接下来,让我们将 Clerk 添加到你的 Next.js 项目中:
创建注册和登录页面
现在,我们需要使用 Clerk 的``和`` 组件。这些组件带有内置 UI 并处理所有身份验证逻辑。
添加页面的方法如下:

完成这些步骤后,Clerk 应该已集成到您的应用中,并且您的登录和注册页面应该可以完全正常运行。
构建工作区仪表板
现在我们已经设置了数据库和身份验证,是时候开始构建工作区仪表板了。此仪表板将是用户可以使用应用程序的不同部分、查看其工作区以及查看其他工作区的邀请的主要区域。
首先,我们需要创建构成用户界面主要部分的组件。
创建导航栏
导航栏是网站不可或缺的一部分,因为它可以帮助用户浏览网站并访问不同的功能。
但是,在此设置中,导航链接不会指向任何地方。这里最有用的项目是其中的“**创建新工作区**”按钮。
在 `components` 文件夹中创建一个 `Navbar.tsx` 文件并添加以下代码:
import { ReactNode } from 'react'; import Button from './Button'; type NavbarProps = { action: () => void; }; const Navbar = ({ action }: NavbarProps) => { return (); }; type NavLinkProps = { dropdown?: boolean; children: ReactNode; }; const NavLink = ({ dropdown = false, children }: NavLinkProps) => { return (
在这里,我们渲染“Navbar”组件,其中包括徽标、占位符链接和用于创建新工作区的按钮。“Navbar”还接受一个“action”属性,当单击“**创建新工作区**”按钮时会触发该属性。
此功能允许我们定义当用户想要创建新工作区时发生的情况。
创建工作区列表组件
要显示用户拥有的所有工作区,我们需要一个“WorkspaceList”组件。此组件将显示工作区详细信息,并允许用户启动工作区或接受邀请。
创建一个名为“/components/WorkspaceList.tsx”的新文件并添加以下代码:
import { Workspace } from '@prisma/client'; import Button from './Button'; interface WorkspaceListProps { action: (formData: FormData) => void; actionText: string; buttonVariant?: 'primary' | 'secondary'; title: string; workspaces: (Omit& { memberCount: number; token?: string; firstChannelId?: string; })[]; } const placeholderImage = 'https://a.slack-edge.com/80588/img/avatars-teams/ava_0014-88.png'; const WorkspaceList = ({ action, actionText, buttonVariant = 'primary', title, workspaces, }: WorkspaceListProps) => { return ( ); }; export default WorkspaceList;{title}{workspaces.map((workspace) => ( ))}
综合起来
现在我们已经创建了工作区仪表板的基本组件,是时候将它们组合在一起并制作主仪表板页面了。我们将使用“Navbar”和“WorkspaceList”组件为我们的工作区应用程序构建一个用户友好的界面。
更新你的 `app/page.tsx` 文件以将所有组件整合在一起并创建仪表板:
import Image from 'next/image'; import { redirect } from 'next/navigation'; import { currentUser } from '@clerk/nextjs/server'; import { SignOutButton } from '@clerk/nextjs'; import Button from '@/components/Button'; import Navbar from '@/components/Navbar'; import prisma from '@/lib/prisma'; import WorkspaceList from '@/components/WorkspaceList'; export default async function Home() { const user = await currentUser(); const userEmail = user?.primaryEmailAddress?.emailAddress; const memberships = await prisma.membership.findMany({ where: { userId: user!.id, }, include: { workspace: { include: { _count: { select: { memberships: true }, }, memberships: { take: 5, }, channels: { take: 1, select: { id: true, }, }, }, }, }, }); const workspaces = memberships.map((membership) => { const { workspace } = membership; return { id: workspace.id, name: workspace.name, image: workspace.image, memberCount: workspace._count.memberships, firstChannelId: workspace.channels[0].id, }; }); const invitations = await prisma.invitation.findMany({ where: { email: userEmail, acceptedAt: null, }, include: { workspace: { include: { _count: { select: { memberships: true }, }, memberships: { take: 5, }, }, }, }, }); const processedInvitations = invitations.map((invitation) => { const { workspace } = invitation; return { id: workspace.id, name: workspace.name, image: workspace.image, memberCount: workspace._count.memberships, token: invitation.token, }; }); async function acceptInvitation(formData: FormData) { 'use server'; const token = String(formData.get('token')); const invitation = await prisma.invitation.findUnique({ where: { token }, }); await prisma.membership.create({ data: { userId: user!.id, email: userEmail!, workspace: { connect: { id: invitation!.workspaceId }, }, role: 'user', }, }); await prisma.invitation.update({ where: { token }, data: { acceptedAt: new Date(), acceptedById: user!.id, }, }); const workspace = await prisma.workspace.findUnique({ where: { id: invitation!.workspaceId }, select: { id: true, channels: { take: 1, select: { id: true, }, }, }, }); redirect(`/client/${workspace!.id}/${workspace!.channels[0].id}`); } async function launchChat(formData: FormData) { 'use server'; const workspaceId = formData.get('workspaceId'); const channelId = formData.get('channelId'); redirect(`/client/${workspaceId}/${channelId}`); } async function goToGetStartedPage() { 'use server'; redirect('/get-started'); } return (); }{/* Workspaces */} Welcome back
{workspaces.length > 0 ? ({/* Create new workspace */}) : ( You are not a member of any workspaces yet.
)}{/* Invitations */}{workspaces.length > 0 ? 'Want to use Slack with a different team?' : 'Want to get started with Slack?'}
{processedInvitations.length > 0 && ()} Not seeing your workspace?
以下是代码每个部分的作用:

创建工作区
构建创建工作区 API
为了允许用户创建新的工作区,我们需要构建一个 API 来处理创建过程和一个可以提供必要详细信息的用户界面。
创建一个 `/api/workspaces/create` 目录,然后添加一个 `route.ts` 文件,内容如下:
import { NextResponse } from 'next/server'; import { auth, currentUser } from '@clerk/nextjs/server'; import prisma from '@/lib/prisma'; import { generateChannelId, generateToken, generateWorkspaceId, isEmail, } from '@/lib/utils'; export async function POST(request: Request) { const { userId } = await auth(); if (!userId) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ); } try { const user = await currentUser(); const userEmail = user?.primaryEmailAddress?.emailAddress; const body = await request.json(); const { workspaceName, channelName, emails, imageUrl } = body; // Validate input if ( !workspaceName || !channelName || !Array.isArray(emails) || emails.length === 0 ) { return NextResponse.json( { error: 'Invalid input data' }, { status: 400 } ); } // Validate emails for (const email of emails) { if (!isEmail(email)) { return NextResponse.json( { error: `Invalid email address: ${email}` }, { status: 400 } ); } } // Create workspace const workspace = await prisma.workspace.create({ data: { id: generateWorkspaceId(), name: workspaceName, image: imageUrl || null, ownerId: userId, }, }); // Create initial channel const channel = await prisma.channel.create({ data: { id: generateChannelId(), name: channelName, workspaceId: workspace.id, }, }); // Add authenticated user as admin await prisma.membership.create({ data: { userId: userId, email: userEmail!, workspace: { connect: { id: workspace.id }, }, role: 'admin', }, }); // Invite provided emails const invitations = []; const skippedEmails = []; const errors = []; for (const email of emails) { try { // Check if an invitation already exists const existingInvitation = await prisma.invitation.findFirst({ where: { email, workspaceId: workspace.id, acceptedAt: null, }, }); // check if the user is already a member const existingMembership = await prisma.membership.findFirst({ where: { email, workspaceId: workspace.id, }, }); if (existingInvitation) { skippedEmails.push(email); continue; } if (existingMembership) { skippedEmails.push(email); continue; } if (email === userEmail) { skippedEmails.push(email); continue; } // Generate token const token = generateToken(); // Create invitation const invitation = await prisma.invitation.create({ data: { email, token, workspaceId: workspace.id, invitedById: userId, }, }); invitations.push(invitation); } catch (error) { console.error(`Error inviting ${email}:`, error); errors.push({ email, error }); } } // Return response const response = { message: 'Workspace created successfully', workspace: { id: workspace.id, name: workspace.name, }, channel: { id: channel.id, name: channelName, }, invitationsSent: invitations.length, invitationsSkipped: skippedEmails.length, errors, }; if (errors.length > 0) { return NextResponse.json(response, { status: 207 }); } else { return NextResponse.json(response, { status: 200 }); } } catch (error) { console.error('Error creating workspace:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } finally { await prisma.$disconnect(); } }
此 API 的功能如下:
最后,API 返回一个响应,其中包含有关新工作区、频道以及成功发送或跳过的邀请数量的详细信息。
构建工作区设置页面
接下来,让我们创建一个页面,用户可以在其中填写设置新工作区所需的信息。此页面将是与我们的 API 交互的用户界面。
在 `/app` 内创建一个 `get-started` 目录,然后在其中创建一个 `page.tsx` 文件并添加以下代码:
'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { isUrl } from '@/lib/utils'; import ArrowDropdown from '@/components/icons/ArrowDropdown'; import Avatar from '@/components/Avatar'; import Button from '@/components/Button'; import Hash from '@/components/icons/Hash'; import Home from '@/components/icons/Home'; import MoreHoriz from '@/components/icons/MoreHoriz'; import RailButton from '@/components/RailButton'; import SidebarButton from '@/components/SidebarButton'; import Tags from '@/components/Tags'; import TextField from '@/components/TextField'; const pattern = `(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`; const GetStarted = () => { const router = useRouter(); const [workspaceName, setWorkspaceName] = useState(''); const [channelName, setChannelName] = useState(''); const [emails, setEmails] = useState([]); const [imageUrl, setImageUrl] = useState(''); const [loading, setLoading] = useState(false); const allFieldsValid = Boolean( workspaceName && channelName && (!imageUrl || (isUrl(imageUrl) && RegExp(pattern).test(imageUrl))) && emails.length > 0 ); const onSubmit = async (e: React.FormEvent ) => { if (allFieldsValid) { e.stopPropagation(); try { setLoading(true); const response = await fetch('/api/workspaces/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspaceName: workspaceName.trim(), channelName: channelName.trim(), emails, imageUrl, }), }); const result = await response.json(); if (response.ok) { alert('Workspace created successfully!'); const { workspace, channel } = result; router.push(`/client/${workspace.id}/${channel.id}`); } else { alert(`Error: ${result.error}`); } } catch (error) { console.error('Error creating workspace:', error); alert('An unexpected error occurred.'); } finally { setLoading(false); } } }; return ( ); }; export default GetStarted;} active /> } /> {channelName && ({workspaceName})}Create a new workspace
在上面的代码中:

创建你的第一个工作区
现在您已完成 API 和设置页面的构建,现在是时候创建您的第一个工作区以确保一切按预期运行。请遵循以下步骤:

通过遵循这些步骤,您可以确认工作区创建流程是否正确运行。
在应用程序中设置流
什么是 Stream?
Stream 是一个平台,允许开发人员为其应用程序添加丰富的聊天和视频功能。Stream 提供 API 和 SDK 来帮助您快速轻松地添加聊天和视频功能,而无需从头开始创建聊天和视频功能。
在这个项目中,我们将使用 Stream 的 React SDK for Video 和 React Chat SDK 在我们的 Slack 克隆中构建聊天和视频通话功能。
创建您的 Stream 帐户

要开始使用 Stream,您需要创建一个帐户:
* After signing up, you'll be asked for additional information, such as your role and industry. * Select the **"Chat Messaging"** and **"Video and Audio"** options since we need these tools for our app.  * Click **"Complete Signup"** to continue.
您现在将被重定向到您的 Stream 仪表板。
创建新的流项目

创建 Stream 帐户后,下一步是为您的项目设置一个应用程序:
* **App Name**: Enter a name like "**Slack Clone**" or any other name you choose. * **Region**: Pick the region nearest to you for the best performance. * **Environment**: Keep it set to "**Development**". * Click the "**Create App**" to finish.
配置用户权限
为了允许用户发送消息、阅读频道和执行其他操作,您需要在 Stream 仪表板中设置必要的权限:

* Create Message * Read Channel * Read Channel Members * Create Reaction * Upload Attachments * Create Attachments
安装 Stream SDK
要开始在 Next.js 项目中使用 Stream,我们需要安装一些 SDK:
将 Clerk 与 Stream 应用同步
为了确保 Clerk 和 Stream 之间的用户数据一致,您需要设置一个同步用户信息的 webhook:
* Go to the [ngrok website](https://dashboard.ngrok.com/signup) and sign up for a free account. * [Download and install ngrok](https://dashboard.ngrok.com/get-started/setup), then start a tunnel to your local server (assuming it's running on port 3000):
```bash ngrok http 3000 --domain=YOUR_DOMAIN ```
Replace `YOUR_DOMAIN` with the [generated ngrok domain](https://dashboard.ngrok.com/cloud-edge/domains).
* **Navigate to Webhooks**: In your [Clerk dashboard](https://dashboard.clerk.com/last-active?path=webhooks), navigate to the “**Configure**” tab and select "**Webhooks**.” * **Add a New Endpoint**: * Click "**Add Endpoint**" and enter your ngrok URL, followed by `/api/webhooks` (e.g., `https://your-subdomain.ngrok.io/api/webhooks`). * Under “**Subscribe to events**”, select `user.created` and `user.updated`. * Click "**Create**". * **Get the Signing Secret**: Copy the signing secret provided and add it to your `.env.local` file:
```dockerfile WEBHOOK_SECRET=your_clerk_webhook_signing_secret ```
Replace `your_clerk_webhook_signing_secret` with the signing secret from the webhooks page. 
* We use Svix's `Webhook` class to verify incoming requests. If the request is valid, we sync the user data with Stream using the `upsertUsers` method for `user.created` and `user.updated` events. * For `user.created` and `user.updated` events, we sync the user data with Stream using `upsertUsers`.
完成这些步骤后,Stream 将在您的应用程序中成功设置。
构建工作空间中心
工作区中心是我们 Slack 克隆版的核心部分,用户可以在这里聊天、进行视频通话和管理他们的工作区。它结合了所有基本功能(例如 Slack 如何组织沟通渠道和工具),使用户可以轻松沟通和协作。
创建布局
我们需要一个布局作为工作区中所有活动的基础。此布局将侧边栏、聊天区和聚会等部分整合在一起。
在 `app` 目录下创建 `client` 文件夹,并添加 `layout.tsx` 文件,内容如下:
'use client'; import { createContext, ReactNode, useEffect, useState } from 'react'; import { Channel, Invitation, Membership, Workspace as PrismaWorkspace, } from '@prisma/client'; import { UserButton, useUser } from '@clerk/nextjs'; import { StreamChat } from 'stream-chat'; import { Chat } from 'stream-chat-react'; import { Call, StreamVideo, StreamVideoClient, } from '@stream-io/video-react-sdk'; import ArrowBack from '@/components/icons/ArrowBack'; import ArrowForward from '@/components/icons/ArrowForward'; import Avatar from '@/components/Avatar'; import Bookmark from '@/components/icons/Bookmark'; import Clock from '@/components/icons/Clock'; import IconButton from '@/components/IconButton'; import Help from '@/components/icons/Help'; import Home from '@/components/icons/Home'; import Plus from '@/components/icons/Plus'; import Messages from '@/components/icons/Messages'; import MoreHoriz from '@/components/icons/MoreHoriz'; import Notifications from '@/components/icons/Notifications'; import RailButton from '@/components/RailButton'; import SearchBar from '@/components/SearchBar'; import WorkspaceLayout from '@/components/WorkspaceLayout'; import WorkspaceSwitcher from '@/components/WorkspaceSwitcher'; interface LayoutProps { children?: ReactNode; params: Promise<{ workspaceId: string }>; } export type Workspace = PrismaWorkspace & { channels: Channel[]; memberships: Membership[]; invitations: Invitation[]; }; export const AppContext = createContext<{ workspace: Workspace; setWorkspace: (workspace: Workspace) => void; otherWorkspaces: Workspace[]; setOtherWorkspaces: (workspaces: Workspace[]) => void; channel: Channel; setChannel: (channel: Channel) => void; loading: boolean; setLoading: (loading: boolean) => void; chatClient: StreamChat; setChatClient: (chatClient: StreamChat) => void; videoClient: StreamVideoClient; setVideoClient: (videoClient: StreamVideoClient) => void; channelCall: Call | undefined; setChannelCall: (call: Call) => void; }>({ workspace: {} as Workspace, setWorkspace: () => {}, otherWorkspaces: [], setOtherWorkspaces: () => {}, channel: {} as Channel, setChannel: () => {}, loading: false, setLoading: () => {}, chatClient: {} as StreamChat, setChatClient: () => {}, videoClient: {} as StreamVideoClient, setVideoClient: () => {}, channelCall: undefined, setChannelCall: () => {}, }); const tokenProvider = async (userId: string) => { const response = await fetch('/api/token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: userId }), }); const data = await response.json(); return data.token; }; const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string; const Layout = ({ children }: LayoutProps) => { const { user } = useUser(); const [loading, setLoading] = useState(true); const [workspace, setWorkspace] = useState(); const [channel, setChannel] = useState (); const [otherWorkspaces, setOtherWorkspaces] = useState ([]); const [chatClient, setChatClient] = useState (); const [videoClient, setVideoClient] = useState (); const [channelCall, setChannelCall] = useState (); useEffect(() => { const customProvider = async () => { const token = await tokenProvider(user!.id); return token; }; const setUpChatAndVideo = async () => { const chatClient = StreamChat.getInstance(API_KEY); const clerkUser = user!; const chatUser = { id: clerkUser.id, name: clerkUser.fullName!, image: clerkUser.imageUrl, custom: { username: user?.username, }, }; if (!chatClient.user) { await chatClient.connectUser(chatUser, customProvider); } setChatClient(chatClient); const videoClient = StreamVideoClient.getOrCreateInstance({ apiKey: API_KEY, user: chatUser, tokenProvider: customProvider, }); setVideoClient(videoClient); }; if (user) setUpChatAndVideo(); }, [user, videoClient, chatClient]); if (!chatClient || !videoClient || !user) return ( ); return (); }; export default Layout; {/* Toolbar */}{!loading && ({/* Main */})}} disabled /> } disabled /> } /> } /> {/* Rail */}{!loading && ( <>} active /> } /> } /> } /> } /> > )}{children}
这里发生了很多事情,让我们来分析一下:
添加 Token API 路由
在上一节中,我们添加了一个 token 提供程序,它向 `/api/token` 发送请求以获取 Stream 用户 token。接下来,我们将创建处理此请求的 API 路由。
创建一个 `/app/api/token` 目录,然后添加一个 `route.ts` 文件,内容如下:
import { StreamClient } from '@stream-io/node-sdk'; const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!; const SECRET = process.env.STREAM_API_SECRET!; export async function POST(request: Request) { const client = new StreamClient(API_KEY, SECRET); const body = await request.json(); const userId = body?.userId; if (!userId) { return Response.error(); } const token = client.generateUserToken({ user_id: userId }); const response = { userId: userId, token: token, }; return Response.json(response); }
在上面的代码中,我们使用 Stream 的 Node SDK 根据用户的“userId”为其创建令牌。此令牌将对用户进行身份验证,以使用 Stream 的聊天和视频功能。
工作区切换器组件
接下来,让我们创建在上一节中添加到布局中的“WorkspaceSwitcher”组件。
在 `components` 目录中创建 `WorkspaceSwitcher.tsx` 文件,并添加以下代码:
import { MutableRefObject, useContext, useState } from 'react'; import { useRouter } from 'next/navigation'; import clsx from 'clsx'; import { AppContext, Workspace } from '@/app/client/layout'; import Avatar from './Avatar'; import Plus from './icons/Plus'; import useClickOutside from '@/hooks/useClickOutside'; const WorkspaceSwitcher = () => { const router = useRouter(); const [open, setOpen] = useState(false); const { workspace, setWorkspace, otherWorkspaces, setOtherWorkspaces, setChannel, } = useContext(AppContext); const domNode = useClickOutside(() => { setOpen(false); }, true) as MutableRefObject; const switchWorkspace = (otherWorkspace: Workspace) => { setOtherWorkspaces([ ...otherWorkspaces.filter((w) => w.id !== otherWorkspace.id), workspace, ]); setWorkspace(otherWorkspace); setChannel(otherWorkspace.channels[0]); router.push( `/client/${otherWorkspace.id}/${otherWorkspace.channels[0].id}` ); }; return ( setOpen((prev) => !prev)} className="relative w-9 h-9 mb-[5px] cursor-pointer" >); }; export default WorkspaceSwitcher; {workspace.name}{workspace.name.replace(/\s/g, '').toLowerCase()}.slack.comNever miss a notification{otherWorkspaces.map((otherWorkspace) => ( ))}Get the Slack app {' '} to see notifications from your other workspaces
在“WorkspaceSwitcher”组件中,当用户点击工作区按钮时,我们有一个下拉菜单。此下拉菜单可让用户轻松在工作区之间切换或添加新工作区。
构建工作区布局组件
接下来,我们将创建在上一节中添加的“WorkspaceLayout”组件,类似于创建“WorkspaceSwitcher”组件的方式。
在 `components` 目录中创建 `WorkspaceLayout.tsx` 文件,并添加以下代码:
'use client'; import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { AppContext } from '../app/client/layout'; import Sidebar from './Sidebar'; interface WorkspaceLayoutProps { children: ReactNode; } const WorkspaceLayout = ({ children }: WorkspaceLayoutProps) => { const { loading } = useContext(AppContext); const layoutRef = useRef(null); const [layoutWidth, setLayoutWidth] = useState(0); useEffect(() => { if (!layoutRef.current) { return; } const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { setLayoutWidth(entry.contentRect.width); } }); resizeObserver.observe(layoutRef.current); return () => { resizeObserver.disconnect(); }; }, [layoutRef]); return ( {/* Sidebar */}); }; export default WorkspaceLayout;{layoutWidth > 0 && {children}}
`WorkspaceLayout` 组件为整个工作区提供了一致的结构。它包括:
添加频道预览组件
接下来,我们将创建“ChannelPreview”组件,它显示工作区中每个频道的预览。
在 `components` 目录中创建 `ChannelPreview.tsx` 文件,并添加以下代码:
import { useContext } from 'react'; import { ChannelPreviewUIComponentProps } from 'stream-chat-react'; import { usePathname, useRouter } from 'next/navigation'; import { AppContext } from '../app/client/layout'; import Hash from './icons/Hash'; import SidebarButton from './SidebarButton'; const ChannelPreview = ({ channel, displayTitle, unread, }: ChannelPreviewUIComponentProps) => { const pathname = usePathname(); const router = useRouter(); const { workspace, setChannel } = useContext(AppContext); const goToChannel = () => { const channelId = channel.id; setChannel(workspace.channels.find((c) => c.id === channelId)!); router.push(`/client/${workspace.id}/${channelId}`); }; const channelActive = () => { const pathChannelId = pathname.split('/').filter(Boolean).pop(); return pathChannelId === channel.id; }; return (); }; export default ChannelPreview;
在上面的代码中:
添加侧边栏
`Sidebar` 组件的主要功能是让用户快速访问频道。
在 `components` 目录中创建 `Sidebar.tsx` 文件,并添加以下代码:
'use client'; import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useUser } from '@clerk/nextjs'; import { ChannelList } from 'stream-chat-react'; import clsx from 'clsx'; import { AppContext } from '../app/client/layout'; import ArrowDropdown from './icons/ArrowDropdown'; import CaretDown from './icons/CaretDown'; import ChannelPreview from './ChannelPreview'; import Compose from './icons/Compose'; import IconButton from './IconButton'; import Refine from './icons/Refine'; import Send from './icons/Send'; import SidebarButton from './SidebarButton'; import Threads from './icons/Threads'; const [minWidth, defaultWidth] = [215, 275]; type SidebarProps = { layoutWidth: number; }; const Sidebar = ({ layoutWidth }: SidebarProps) => { const { user } = useUser(); const { loading, workspace } = useContext(AppContext); const [width, setWidth] = useState(() => { const savedWidth = parseInt(window.localStorage.getItem('sidebarWidth') as string) || defaultWidth; window.localStorage.setItem('sidebarWidth', String(savedWidth)); return savedWidth; }); const maxWidth = useMemo(() => layoutWidth - 374, [layoutWidth]); const isDragged = useRef(false); useEffect(() => { if (!layoutWidth) return; const onMouseMove = (e: MouseEvent) => { if (!isDragged.current) { return; } document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; document.querySelectorAll('.sidebar-btn').forEach((el) => { el.setAttribute('style', 'cursor: col-resize'); }); setWidth((previousWidth) => { const newWidth = previousWidth + e.movementX / 1.3; if (newWidth < minWidth) { return minWidth; } else if (newWidth > maxWidth) { return maxWidth; } return newWidth; }); }; const onMouseUp = () => { document.body.style.userSelect = 'auto'; document.body.style.cursor = 'auto'; document.querySelectorAll('.sidebar-btn').forEach((el) => { el.removeAttribute('style'); }); isDragged.current = false; }; window.removeEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', () => onMouseUp); }; }, [layoutWidth, maxWidth]); useEffect(() => { if (!layoutWidth || layoutWidth < 0) return; if (width) { let newWidth = width; if (width > maxWidth) { newWidth = maxWidth; } setWidth(newWidth); localStorage.setItem('sidebarWidth', String(width)); } }, [width, layoutWidth, maxWidth]); return (