ساخت Slack Clone با Next.js و TailwindCSS – قسمت اول

همکاری برای موفقیت ضروری است و ایجاد ابزاری که به تیم ها کمک می کند بهتر کار کنند می تواند سرگرم کننده و پاداش دهنده باشد. با تعداد بیشتری از افراد که از خانه کار میکنند، ساخت اپلیکیشنی که به همه کمک میکند از طریق پیامرسانی، ویدیویی و چتهای گروهی در ارتباط باشند، میتواند تفاوت بزرگی ایجاد کند.
در این مجموعه سه قسمتی، ما یک کلون Slack خواهیم ساخت – برنامه ای که به تیم ها کمک می کند تا با پیام های فوری، تماس های ویدیویی و کانال ها در تماس باشند. ما این برنامه را با استفاده از React (Next.js)، TailwindCSS، Prisma و Stream SDK میسازیم.
در این قسمت اول، با راهاندازی پروژه و ساختن اولین رابط کاربری، از جمله صفحه کانال، اصول اولیه را تنظیم میکنیم.
در بخش دوم، پیامرسانی و کانالهای بلادرنگ را با استفاده از Stream React Chat SDK اضافه میکنیم. در نهایت، در قسمت سوم، تماسهای ویدیویی (مانند Slack Huddles) را با استفاده از Stream React Video و Audio SDK اضافه میکنیم و آخرین نکات را اضافه میکنیم.
در پایان این مجموعه، شما یک برنامه همکاری قوی ساخته اید که ویژگی های اساسی Slack را منعکس می کند.
در اینجا نگاهی اجمالی به ظاهر محصول نهایی داریم:
https://www.youtube.com/watch?v=NoFzCn5sbts
میتوانید نسخه آزمایشی زنده را بررسی کنید و به کد منبع کامل در GitHub دسترسی پیدا کنید.
بیایید شروع کنیم!
پیش نیازها
قبل از شروع پروژه، مطمئن شوید که موارد زیر را دارید:
-
درک اولیه React: شما باید در ساخت اجزا، مدیریت وضعیت و درک نحوه عملکرد اجزا راحت باشید.
-
Node.js و npm: مطمئن شوید که Node.js و npm (Node Package Manager) روی رایانه شما نصب شده باشند. این برای اجرا و ساخت پروژه ما مهم است.
-
آشنایی با TypeScript، Next.js و TailwindCSS Basics: ما از این ابزارها زیاد استفاده خواهیم کرد، بنابراین دانستن اصول اولیه به شما کمک می کند تا به راحتی آن را دنبال کنید.
راه اندازی پروژه
بیایید با راه اندازی پروژه خود شروع کنیم. ما با شبیه سازی یک الگوی شروع که حاوی کد راه اندازی اولیه و ساختار پوشه است شروع می کنیم تا به ما در شروع سریع کمک کند:
# 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
ساختار پروژه باید به شکل زیر باشد:
این پروژه به گونه ای سازماندهی شده است که کد را مرتب نگه می دارد و مدیریت آن آسان است:
-
فهرست کامپوننت ها: این پوشه دارای تمام قسمت های قابل استفاده مجدد رابط کاربری مانند آیکون ها، دکمه ها و سایر اجزای پایه است.
-
دایرکتوری هوکس: پوشه hooks دارای قلاب های سفارشی React مانند است
useClickOutside
، که ما از آن برای رسیدگی به تعاملات خاص کاربر استفاده خواهیم کرد. -
فهرست راهنمای Lib: این پوشه شامل توابع کاربردی مانند
utils.ts
که وظایف مشترک را در سراسر برنامه ساده می کند.
راه اندازی پایگاه داده
برای ساخت اپلیکیشنی مشابه Slack، باید بتوانیم اطلاعات مربوط به فضاهای کاری، کانال ها، اعضا و دعوت نامه ها را در یک پایگاه داده ذخیره کنیم.
ما از Prisma برای کمک به تعامل آسان با این پایگاه داده استفاده خواهیم کرد.
پریسما چیست؟
Prisma یک ابزار منبع باز ORM (نگاشت شی-رابطه ای) است که به ما امکان می دهد ساختار پایگاه داده خود را تعریف کنیم و پرس و جوها را به طور موثر اجرا کنیم. با Prisma، می توانید بدون نیاز به مدیریت مستقیم SQL، عملیات پایگاه داده را به طور مستقیم بنویسید، که کارها را ساده تر می کند و خطاها را کاهش می دهد.
نصب پریسما
بیایید با نصب 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 Migrations
برای ایجاد جداول پایگاه داده بر اساس طرح ما، دستور زیر را اجرا کنید:
npx prisma migrate dev --name init
این دستور جداول را برای مدل هایی که در پایگاه داده تعریف کرده ایم تنظیم می کند. همچنین به ما کمک می کند تغییرات در تنظیمات پایگاه داده خود را در طول توسعه پیگیری کنیم.
پس از اجرای مهاجرت، با اجرای دستور زیر، کلاینت Prisma را ایجاد کنید:
npx prisma generate
این دستور کلاینت Prisma را ایجاد می کند، که به ما امکان می دهد در سراسر کدمان به طور ایمن و قابل اعتماد با پایگاه داده کار کنیم.
راه اندازی Prisma Client در کد
برای استفاده از مشتری Prisma در پروژه خود، یک جدید ایجاد کنید prisma.ts
فایل در lib
دایرکتوری با کد زیر:
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 پلتفرمی است که با ارائه ابزارهایی برای احراز هویت و پروفایل های کاربر به مدیریت کاربران کمک می کند. این شامل اجزای رابط کاربری آماده، APIها و داشبوردی برای مدیران است که افزودن ویژگیهای احراز هویت به برنامه شما را بسیار سادهتر میکند. به جای اینکه خودتان یک سیستم احراز هویت کامل بسازید، Clerk با ارائه این ویژگی ها در زمان و تلاش صرفه جویی می کند.
در این پروژه، ما از Clerk برای رسیدگی به احراز هویت کاربر استفاده خواهیم کرد.
راه اندازی یک حساب کارمند
ابتدا باید یک حساب کاربری رایگان با Clerk ایجاد کنید. به صفحه ثبت نام کارمند بروید و با استفاده از ایمیل یا گزینه ورود به سیستم اجتماعی ثبت نام کنید.
ایجاد پروژه کارمند
پس از ورود به سیستم، می توانید یک پروژه جدید در Clerk برای برنامه خود ایجاد کنید:
-
به داشبورد بروید و روی “کلیک کنید”ایجاد اپلیکیشن“.
-
نام برنامه خود را “کلون شل“.
-
زیر “گزینه های ورود به سیستم،” انتخاب کنید ایمیل، نام کاربری، و گوگل.
-
روی ” کلیک کنیدایجاد اپلیکیشن” برای تکمیل تنظیمات.
پس از ایجاد پروژه، صفحه نمای کلی برنامه را مشاهده خواهید کرد که حاوی شماست کلید قابل انتشار و کلید مخفی– اینها را در دسترس داشته باشید زیرا بعداً به آنها نیاز خواهید داشت.
در مرحله بعد، فیلدهای نام و نام خانوادگی را در هنگام ثبت نام ایجاد می کنیم:
-
به داشبورد خود بروید “پیکربندی” برگه
-
تحت “کاربر و احراز هویت“، انتخاب کنید”ایمیل، تلفن، نام کاربری“.
-
پیدا کردن “نام“گزینه در”اطلاعات شخصیبخش ” و آن را روشن کنید..
-
روی نماد چرخ دنده در کنار ” کلیک کنیدنام” و آن را به عنوان مورد نیاز تنظیم کنید.
-
کلیک کنید ”ادامه دهید” برای ذخیره تغییرات شما.
نصب کارمند در پروژه شما
سپس، بیایید Clerk را به پروژه Next.js خود اضافه کنیم:
-
بسته Clerk را با اجرای دستور زیر نصب کنید:
npm install @clerk/nextjs
-
ایجاد کنید
.env.local
فایل و متغیرهای محیطی زیر را اضافه کنید:NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key CLERK_SECRET_KEY=your_clerk_secret_key
جایگزین کنید
your_clerk_publishable_key
وyour_clerk_secret_key
با کلیدهای صفحه نمای کلی پروژه شما. -
برای استفاده از احراز هویت Clerk در سراسر برنامه، باید برنامه خود را با آن بپیچید
ClerkProvider
. خود را به روز کنیدapp/layout.tsx
فایل مانند این:import type { Metadata } from 'next'; import { ClerkProvider } from '@clerk/nextjs'; ... export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <ClerkProvider> <html lang="en"> <body className="text-white bg-purple antialiased">{children}</body> </html> </ClerkProvider> ); }
ایجاد صفحات ثبت نام و ورود به سیستم
اکنون، باید صفحات ثبت نام و ورود را با استفاده از Clerk's راه اندازی کنیم
و
اجزاء این مؤلفهها با رابط کاربری داخلی همراه هستند و تمام منطق احراز هویت را مدیریت میکنند.
در اینجا نحوه اضافه کردن صفحات آمده است:
-
URL های احراز هویت را تنظیم کنید: منشی
و
اجزا باید بدانند در کجای برنامه شما نصب شده اند. این مسیرها را به مسیر خود اضافه کنید.env.local
فایل:NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
-
صفحه ثبت نام را ایجاد کنید: ایجاد یک صفحه ثبت نام در
app/sign-up/[[...sign-up]]/page.tsx
و کد زیر را اضافه کنید:import { SignUp } from '@clerk/nextjs'; export default function Page() { return ( <div className="sm:w-svw sm:h-svh bg-purple w-full h-full flex items-center justify-center"> <SignUp /> </div> ); }
-
صفحه ورود به سیستم را ایجاد کنید: ایجاد یک
page.tsx
فایل درapp/sign-in/[[...sign-in]]
دایرکتوری و کد زیر را اضافه کنید:import { SignIn } from '@clerk/nextjs'; export default function Page() { return ( <div className="w-svw h-svh bg-purple flex items-center justify-center"> <SignIn /> </div> ); }
-
میان افزار کارمند خود را اضافه کنید: منشی با الف می آید
clerkMiddleware()
کمکی که احراز هویت را در پروژه Next.js ما ادغام می کند. ما می توانیم از این میان افزار برای محافظت از برخی مسیرها و در عین حال عمومی نگه داشتن مسیرهای دیگر استفاده کنیم.در مورد ما، ما تنها مسیرهای ثبت نام و ورود به سیستم را می خواهیم که برای همه در دسترس باشد و در عین حال از مسیرهای دیگر محافظت کنیم. برای انجام این کار، یک را ایجاد کنید
middleware.ts
فایل درsrc
دایرکتوری با کد زیر:import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isPublicRoute = createRouteMatcher([ '/sign-in(.*)', '/sign-up(.*)', ]); export default clerkMiddleware(async (auth, request) => { if (!isPublicRoute(request)) { await auth.protect(); } }); export const config = { matcher: [ // Skip Next.js internals and all static files, unless found in search params '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', // Always run for API routes '/(api|trpc)(.*)', ], };
با تکمیل این مراحل، Clerk باید در برنامه شما ادغام شود و صفحات ثبت نام و ثبت نام شما باید کاملاً کاربردی باشد.
ساخت داشبورد فضای کاری
اکنون که پایگاه داده و احراز هویت خود را راه اندازی کرده ایم، زمان شروع ساخت داشبورد فضای کاری است. این داشبورد منطقه اصلی خواهد بود که در آن کاربران می توانند از قسمت های مختلف برنامه استفاده کنند، فضاهای کاری خود را مشاهده کنند و دعوت به فضاهای کاری دیگر را ببینند.
برای شروع، باید اجزایی ایجاد کنیم که بخشهای اصلی رابط کاربری ما را تشکیل میدهند.
ایجاد نوار ناوبری
نوار ناوبری برای وب سایت ها یکپارچه است زیرا به کاربران کمک می کند تا در سایت ها حرکت کنند و به ویژگی های مختلف دسترسی پیدا کنند.
با این حال، در این تنظیمات، لینک های ناوبری به جایی نمی رسند. مورد مفید اولیه در اینجا “یک فضای کاری جدید ایجاد کنیددکمه درون آن
ایجاد یک Navbar.tsx
فایل در components
پوشه و کد زیر را اضافه کنید:
import { ReactNode } from 'react';
import Button from './Button';
type NavbarProps = {
action: () => void;
};
const Navbar = ({ action }: NavbarProps) => {
return (
<header>
<nav className="bg-purple h-20">
<div className="flex justify-between h-full px-[4vw] mx-auto">
<div className="flex items-center w-[125px] justify-start">
<div className="flex items-center gap-1.5">
<div className="w-[26px] h-[26px]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<path
d="M27.255 80.719c0 7.33-5.978 13.317-13.309 13.317C6.616 94.036.63 88.049.63 80.719s5.987-13.317 13.317-13.317h13.309zm6.709 0c0-7.33 5.987-13.317 13.317-13.317s13.317 5.986 13.317 13.317v33.335c0 7.33-5.986 13.317-13.317 13.317-7.33 0-13.317-5.987-13.317-13.317zm0 0"
fill="#de1c59"
/>
<path
d="M47.281 27.255c-7.33 0-13.317-5.978-13.317-13.309C33.964 6.616 39.951.63 47.281.63s13.317 5.987 13.317 13.317v13.309zm0 6.709c7.33 0 13.317 5.987 13.317 13.317s-5.986 13.317-13.317 13.317H13.946C6.616 60.598.63 54.612.63 47.281c0-7.33 5.987-13.317 13.317-13.317zm0 0"
fill="#35c5f0"
/>
<path
d="M100.745 47.281c0-7.33 5.978-13.317 13.309-13.317 7.33 0 13.317 5.987 13.317 13.317s-5.987 13.317-13.317 13.317h-13.309zm-6.709 0c0 7.33-5.987 13.317-13.317 13.317s-13.317-5.986-13.317-13.317V13.946C67.402 6.616 73.388.63 80.719.63c7.33 0 13.317 5.987 13.317 13.317zm0 0"
fill="#2eb57d"
/>
<path
d="M80.719 100.745c7.33 0 13.317 5.978 13.317 13.309 0 7.33-5.987 13.317-13.317 13.317s-13.317-5.987-13.317-13.317v-13.309zm0-6.709c-7.33 0-13.317-5.987-13.317-13.317s5.986-13.317 13.317-13.317h33.335c7.33 0 13.317 5.986 13.317 13.317 0 7.33-5.987 13.317-13.317 13.317zm0 0"
fill="#ebb02e"
/>
</svg>
</div>
<span className="text-[29px] font-outfit font-bold">slack</span>
</div>
</div>
<div className="hidden sm:flex items-center text-sm flex-1">
<ul className="flex flex-1 leading-[1.555] -tracking-[.0012em]">
<NavLink dropdown>Features</NavLink>
<NavLink dropdown>Solutions</NavLink>
<NavLink>Enterprise</NavLink>
<NavLink dropdown>Resources</NavLink>
<NavLink>Pricing</NavLink>
</ul>
<button className="hidden lg:flex mt-1 mr-6">
<svg
width="20"
height="20"
fill="white"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m18.78 17.72c.1467.1467.22.3233.22.53 0 .2133-.0733.39-.22.53-.16.1467-.3367.22-.53.22-.2067 0-.3833-.0733-.53-.22l-4.47-4.47c-.6667.54-1.4067.9567-2.22 1.25-.8067.2933-1.65.44-2.53.44-1.36 0-2.61333-.3367-3.76-1.01s-2.05667-1.5833-2.73-2.73-1.01-2.4-1.01-3.76.33667-2.61333 1.01-3.76 1.58333-2.05667 2.73-2.73 2.4-1.01 3.76-1.01 2.6133.33667 3.76 1.01 2.0567 1.58333 2.73 2.73 1.01 2.4 1.01 3.76c0 .88-.1467 1.7267-.44 2.54-.2933.8067-.71 1.5433-1.25 2.21zm-10.28-3.22c1.08667 0 2.0867-.27 3-.81.92-.54 1.65-1.2667 2.19-2.18.54-.92.81-1.92333.81-3.01s-.27-2.08667-.81-3c-.54-.92-1.27-1.65-2.19-2.19-.9133-.54-1.91333-.81-3-.81s-2.09.27-3.01.81c-.91333.54-1.64 1.27-2.18 2.19-.54.91333-.81 1.91333-.81 3s.27 2.09.81 3.01c.54.9133 1.26667 1.64 2.18 2.18.92.54 1.92333.81 3.01.81z"
stroke="#fff"
strokeWidth=".5"
></path>
</svg>
</button>
<form action={action}>
<Button
type="submit"
variant="secondary"
className="hidden lg:flex ml-2 py-0 w-[240px] h-[45px]"
>
<span>Create a new workspace</span>
</Button>
</form>
</div>
</div>
</nav>
</header>
);
};
type NavLinkProps = {
dropdown?: boolean;
children: ReactNode;
};
const NavLink = ({ dropdown = false, children }: NavLinkProps) => {
return (
<li className="p-[.25rem_.88rem]">
<button className="text-[15.5px] font-semibold flex items-center gap-1">
<span>{children}</span>
{dropdown && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="8"
height="5"
viewBox="0 0 8 5"
fill="none"
>
<path
d="M7 1L4 4L1 1"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
</li>
);
};
export default Navbar;
در اینجا، ما را ارائه می دهیم Navbar
کامپوننت، که شامل لوگو، پیوندهای نگهدارنده مکان، و دکمه ای برای ایجاد یک فضای کاری جدید است. را Navbar
یک را نیز می گیرد action
پروپوزال، که وقتی فعال می شودیک فضای کاری جدید ایجاد کنیددکمه ' کلیک می شود.
این تابع به ما امکان می دهد تعریف کنیم که وقتی کاربران می خواهند یک فضای کاری جدید ایجاد کنند چه اتفاقی می افتد.
ایجاد کامپوننت لیست فضای کاری
برای نمایش تمام فضاهای کاری یک کاربر، به a نیاز داریم 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<Workspace, 'ownerId'> & {
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 (
<div className="rounded-[9px] mb-12 border-[#fff3] border-4">
<div className="flex items-center bg-[#ecdeec] text-black p-4 text-lg rounded-t-[5px] min-h-[calc(50px+2rem)]">
{title}
</div>
<div className="flex flex-col rounded-b-[5px] bg-[#fff] [&>:not(:first-child)]:border [&>:not(:first-child)]:border-t-[#ebeaeb]">
{workspaces.map((workspace) => (
<form action={action} key={workspace.id} className="p-4">
<input
type="hidden"
name="channelId"
value={workspace?.firstChannelId}
/>
<input type="hidden" name="token" value={workspace?.token} />
<input type="hidden" name="workspaceId" value={workspace.id} />
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-0">
<div className="flex items-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={workspace.image || placeholderImage}
alt="workspace-image"
className="rounded-[5px] mr-4 h-[75px] w-[75px] object-cover"
/>
<div className="flex flex-col my-auto text-black">
<span className="text-lg font-bold mb-2">
{workspace.name}
</span>
<div className="flex h-5">
<span className="text-[#696969] text-[14.5px]">
{workspace.memberCount} member
{workspace.memberCount !== 1 && 's'}
</span>
</div>
</div>
</div>
<div className="sm:ml-auto w-full sm:w-auto flex sm:block">
<Button
type="submit"
variant={buttonVariant}
className="grow shrink-0"
>
<span>{actionText}</span>
</Button>
</div>
</div>
</form>
))}
</div>
</div>
);
};
export default WorkspaceList;
-
کامپوننت چندین ویژگی دارد:
-
action
: تابعی که تعریف می کند وقتی دکمه عمل کلیک می کند چه اتفاقی می افتد. -
actionText
: متن نمایش داده شده روی دکمه برای هر فضای کاری. -
buttonVariant
: سبک دکمه را مشخص می کند، یا “اصلی” یا “ثانویه”. -
title
: عنوان لیست فضای کاری. -
workspaces
: آرایه ای از اشیاء فضای کاری با جزئیات بیشتر.
-
-
برای هر فضای کاری، ما نشان می دهیم:
- نام: نام فضای کاری.
- تصویر: اگر تصویری ارائه نشده باشد، تصویر فضای کار یا مکاننما.
- تعداد اعضا: تعداد اعضا در فضای کاری.
-
هر مورد فضای کاری نیز فرمی است که دارای فیلدهای ورودی مخفی است. این فیلدها داده های ضروری مانند
channelId
،token
، وworkspaceId
. وقتی کاربر روی دکمه ارسال کلیک میکند، این ورودیها اطلاعات مورد نیاز را برای اقدام برای آن فضای کاری ارسال میکنند.
قرار دادن آن همه با هم
اکنون که اجزای ضروری داشبورد فضای کاری را ایجاد کرده ایم، وقت آن است که آنها را کنار هم قرار دهیم و صفحه اصلی داشبورد را بسازیم. ما استفاده خواهیم کرد 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 (
<div className="font-lato min-h-screen text-white">
<Navbar action={goToGetStartedPage} />
<section className="mt-9 max-w-[62.875rem] mx-auto px-[4vw]">
{/* Workspaces */}
<div className="flex items-center gap-1 mb-6">
<Image
src="https://a.slack-edge.com/6c404/marketing/img/homepage/bold-existing-users/waving-hand.gif"
width={52}
height={56}
alt="waving-hand"
unoptimized
/>
<h1 className="text-[40px] sm:text-[55.5px] leading-[1.12] font-outfit font-semibold">
Welcome back
</h1>
</div>
<div className="mb-12">
{workspaces.length > 0 ? (
<WorkspaceList
title={`Workspaces for ${userEmail}`}
workspaces={workspaces}
action={launchChat}
actionText="Launch Slack"
/>
) : (
<p className="text-lg font-bold pt-4">
You are not a member of any workspaces yet.
</p>
)}
</div>
{/* Create new workspace */}
<div className="rounded-[9px] mb-12 border-[#fff3] border-4">
<div className="flex flex-col sm:grid items-center bg-[#fff] p-4 grid-rows-[1fr] grid-cols-[200px_1fr_auto] rounded-[5px]">
<Image
src="https://a.slack-edge.com/613463e/marketing/img/homepage/bold-existing-users/create-new-workspace-module/woman-with-laptop-color-background.png"
width={200}
height={121}
className="rounded-[5px] m-[-1rem_-1rem_-47px]"
alt="woman-with-laptop"
/>
<p className="mt-[50px] text-center sm:text-start mb-3 sm:my-0 pr-4 tracking-[.02em] text-[17.8px] text-black">
<strong>
{workspaces.length > 0
? 'Want to use Slack with a different team?'
: 'Want to get started with Slack?'}
</strong>
</p>
<form action={goToGetStartedPage}>
<Button type="submit" variant="secondary">
Create a new workspace
</Button>
</form>
</div>
</div>
{/* Invitations */}
<div className="mb-12">
{processedInvitations.length > 0 && (
<WorkspaceList
title={`Invitations for ${userEmail}`}
workspaces={processedInvitations}
action={acceptInvitation}
actionText="Accept invite"
buttonVariant="secondary"
/>
)}
</div>
<SignOutButton redirectUrl="/sign-in">
<div className="flex flex-col sm:flex-row items-center justify-center mb-12">
<p className="mr-2 text-lg leading-[1.555] tracking-[-.0012em]">
Not seeing your workspace?
</p>
<button className="text-lg leading-[1.555] tracking-[.012em] text-[#36c5f0] ml-2 flex items-center gap-[9px]">
<span>Try using a different email</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-[19px] h-[13px]"
fill="none"
>
<path
d="M1 6a.5.5 0 0 0 0 1V6zM12.854.646a.5.5 0 0 0-.708.708l.708-.708zM18 6.5l.354.354a.5.5 0 0 0 0-.708L18 6.5zm-5.854 5.146a.5.5 0 0 0 .708.708l-.708-.708zM1 7h16.5V6H1v1zm16.646-.854l-5.5 5.5.708.708 5.5-5.5-.708-.708zm-5.5-4.792l2.75 2.75.708-.708-2.75-2.75-.708.708zm2.75 2.75l2.75 2.75.708-.708-2.75-2.75-.708.708z"
fill="#36c5f0"
/>
</svg>
</button>
</div>
</SignOutButton>
</section>
</div>
);
}
در اینجا کاری است که هر قسمت از کد انجام می دهد:
-
اطلاعات کاربر: عملکرد با بازیابی اطلاعات کاربر فعلی با استفاده از Clerk شروع می شود.
-
داده های فضای کاری: از پایگاه داده پرس و جو می کند تا تمام فضاهای کاری که کاربر به آنها تعلق دارد و هر دعوت نامه در انتظاری را دریافت کند.
-
توابع برای اقدامات: در اینجا سه تابع اصلی تعریف شده است:
-
acceptInvitation()
: دعوت نامه را می پذیرد و کاربر را به فضای کاری مناسب هدایت می کند. -
launchChat()
: چت فضای کاری انتخاب شده را با هدایت به URL صحیح راه اندازی می کند. -
goToGetStartedPage()
: برای ایجاد یک فضای کاری جدید به صفحه “شروع به کار” هدایت می شود.
-
-
در نهایت، ما را برمی گردانیم
Navbar
،WorkspaceList
، و منشیSignOutButton
دکمه برای ارائه یک رابط خوشامدگویی.
ایجاد یک فضای کاری
ساخت Create 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 پاسخی را با جزئیات مربوط به فضای کاری جدید، کانال و تعداد دعوتنامههایی که با موفقیت ارسال شده یا نادیده گرفته شده است، برمیگرداند.
ساخت صفحه راه اندازی فضای کاری
در مرحله بعد، بیایید صفحه ای ایجاد کنیم که در آن کاربران می توانند اطلاعات مورد نیاز برای راه اندازی یک فضای کاری جدید را پر کنند. این صفحه رابط کاربری برای تعامل با API ما خواهد بود.
ایجاد یک get-started
دایرکتوری داخل /app
، سپس یک را ایجاد کنید 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<string[]>([]);
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<HTMLFormElement>) => {
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 (
<div className="client font-lato w-screen h-screen flex flex-col">
<div className="absolute w-full h-full bg-theme-gradient" />
<div className="relative w-full h-10 flex items-center justify-between pr-1"></div>
<div className="w-screen h-[calc(100svh-40px)] grid grid-cols-[70px_auto]">
<div className="hidden relative w-[4.375rem] sm:flex flex-col items-center overflow-hidden gap-3 pt-2 z-[1000] bg-transparent">
<div className="w-9 h-9 mb-[5px]">
<Avatar
width={36}
borderRadius={8}
fontSize={20}
fontWeight={600}
data={{ name: workspaceName, image: imageUrl }}
/>
</div>
<div className="relative flex flex-col items-center w-[3.25rem]">
<div className="relative">
<RailButton
title="Home"
icon={<Home color="var(--primary)" filled />}
active
/>
<div className="absolute w-full h-full top-0 left-0" />
</div>
<div className="relative opacity-30">
<RailButton
title="More"
icon={<MoreHoriz color="var(--primary)" />}
/>
<div className="absolute w-full h-full top-0 left-0" />
</div>
</div>
</div>
<div className="relative w-svw h-full sm:h-auto sm:w-auto flex mr-1 mb-1 rounded-md overflow-hidden border border-solid border-[#797c814d]">
<div className="hidden w-[275px] relative px-2 sm:flex flex-col flex-shrink-0 gap-3 min-w-0 min-h-0 max-h-[calc(100svh-44px)] bg-[#10121499] border-r-[1px] border-solid border-r-[#797c814d]">
<div className="pl-1 w-full h-[49px] flex items-center justify-between">
<div className="max-w-[calc(100%-80px)]">
<div className="w-fit max-w-full rounded-md py-[3px] px-2 flex items-center text-white hover:bg-hover-gray">
<span className="truncate text-[18px] font-[900] leading-[1.33334]">
{workspaceName}
</span>
</div>
</div>
</div>
{channelName && (
<div className="w-full flex flex-col">
<div className="h-7 -ml-1.5 flex items-center px-4 text-[15px] leading-7">
<button className="hover:bg-hover-gray rounded-md">
<ArrowDropdown color="var(--icon-gray)" />
</button>
<button className="flex px-[5px] max-w-full rounded-md text-sidebar-gray font-medium hover:bg-hover-gray">
Channels
</button>
</div>
<SidebarButton icon={Hash} title={channelName} />
</div>
)}
<div className="absolute w-full h-full top-0 left-0" />
</div>
<div className="bg-[#1a1d21] grow p-16 flex flex-col">
<div className="max-w-[705px] flex flex-col gap-8">
<h2 className="max-w-[632px] font-sans font-bold mb-2 text-[45px] leading-[46px] text-white">
Create a new workspace
</h2>
<form onSubmit={onSubmit} action={() => {}} className="contents">
<TextField
label="Workspace name"
name="workspaceName"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="Enter a name for your workspace"
required
/>
<TextField
label={
<span>
Workspace image{' '}
<span className="text-[#9a9b9e] ml-0.5">(optional)</span>
</span>
}
name="workspaceImage"
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="Paste an image URL"
pattern={`(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`}
title='Image URL must start with "http://" or "https://" and end with ".png", ".jpg", ".jpeg", ".gif", or ".svg"'
/>
<TextField
label="Channel name"
name="channelName"
value={channelName}
onChange={(e) =>
setChannelName(
e.target.value.toLowerCase().replace(/\s/g, '-')
)
}
placeholder="Enter a name for your first channel"
maxLength={80}
required
/>
<Button
type="submit"
disabled={emails.length === 0}
className="w-fit order-5 capitalize py-2 hover:bg-[#592a5a] hover:border-[#592a5a]"
loading={loading}
>
Submit
</Button>
</form>
<Tags
values={emails}
setValues={setEmails}
label="Invite members"
placeholder="Enter email addresses"
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default GetStarted;
در کد بالا:
-
این صفحه به کاربران امکان می دهد جزئیاتی را برای ایجاد یک فضای کاری جدید ارائه دهند، از جمله:
- نام فضای کاری: نام فضای کاری.
- نام کانال: نام اولین کانالی که در فضای کاری ایجاد می شود.
- ایمیل اعضا برای دعوت: کاربران می توانند فهرستی از آدرس های ایمیل را برای دعوت اعضا به فضای کاری وارد کنند.
- تصویر فضای کاری (اختیاری): کاربران می توانند به صورت اختیاری یک URL تصویر برای نمایش فضای کاری ارائه دهند.
-
فرم از
useState
قلاب برای ذخیره مقادیری که کاربر وارد می کند و استفاده می کندallFieldsValid
برای اطمینان از اینکه تمام فیلدهای مورد نیاز به درستی پر شده اند. -
هنگامی که فرم ارسال می شود، یک را ایجاد می کند
POST
درخواست به/api/workspaces/create
مسیر، عبور از جزئیات فضای کاری. در صورت موفقیت آمیز بودن درخواست، کاربر را به فضای کاری جدید هدایت می کنیم. -
این رابط در هنگام پردازش درخواست، بازخورد بصری ارائه می دهد، مانند نشان دادن وضعیت بارگیری یا پیام های خطا در صورت بروز مشکل.
ایجاد اولین فضای کاری شما
اکنون که ساختن API و صفحه راهاندازی را به پایان رساندهاید، زمان آن رسیده است که اولین فضای کاری خود را ایجاد کنید تا مطمئن شوید همه چیز مطابق انتظار کار میکند. این مراحل را دنبال کنید:
-
به صفحه تنظیمات بروید: به برنامه خود بروید
/get-started
صفحه -
مشخصات لازم را پر کنید: نام فضای کاری، نام کانال و آدرس ایمیل اعضایی را که می خواهید دعوت کنید وارد کنید.
-
افزودن تصویر (اختیاری): در صورت تمایل، یک URL تصویر اضافه کنید تا فضای کاری شخصی تر به نظر برسد.
-
فرم را ارسال کنید: برای ایجاد فضای کاری روی دکمه «ارسال» کلیک کنید.
-
ایجاد را تأیید کنید: مطمئن شوید که فضای کاری با موفقیت ایجاد شده است و همه اعضای دعوت شده دعوت نامه دریافت می کنند.
-
داشبورد را بررسی کنید: بررسی کنید که فضای کاری جدید به درستی در داشبورد شما فهرست شده باشد و کانال اولیه قابل مشاهده باشد.
با دنبال کردن این مراحل، می توانید تأیید کنید که جریان ایجاد فضای کاری به درستی کار می کند.
راه اندازی استریم در برنامه شما
استریم چیست؟
Stream پلتفرمی است که به توسعه دهندگان اجازه می دهد تا ویژگی های چت و ویدیوی غنی را به برنامه های خود اضافه کنند. به جای پرداختن به پیچیدگی ایجاد چت و ویدیو از پایه، استریم API و SDK را ارائه میکند تا به شما کمک کند آنها را سریع و آسان اضافه کنید.
در این پروژه، ما از React SDK Stream برای ویدیو و React Chat SDK برای ساخت ویژگیهای چت و تماس ویدیویی در Slack clone خود استفاده خواهیم کرد.
ایجاد حساب جریانی شما
برای شروع استفاده از 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 خود، روی ” کلیک کنیدایجاد اپلیکیشندکمه “
-
برنامه خود را پیکربندی کنید:
* **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.
-
کلیدهای API خود را دریافت کنید: پس از ایجاد برنامه، به مسیر “کلیدهای دسترسی به برنامهبخش “. برای اتصال Stream به پروژه خود به این کلیدها نیاز دارید.
پیکربندی مجوزهای کاربر
برای اجازه دادن به کاربران برای ارسال پیام، خواندن کانال ها و انجام سایر اقدامات، باید مجوزهای لازم را در داشبورد Stream تنظیم کنید:
-
به “نقش ها و مجوزها“برگه زیر”چت پیام“
-
انتخاب کنید “کاربر“نقش و انتخاب”پیام رسانی” دامنه
-
روی ” کلیک کنیدویرایش کنید” را فشار دهید و مجوزهای زیر را انتخاب کنید:
* Create Message
* Read Channel
* Read Channel Members
* Create Reaction
* Upload Attachments
* Create Attachments
- تغییرات را ذخیره و تایید کنید.
نصب Stream SDK
برای شروع استفاده از Stream در پروژه Next.js، باید چند SDK نصب کنیم:
-
SDK ها را نصب کنید: برای نصب بسته های لازم دستور زیر را اجرا کنید:
npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat
-
تنظیم متغیرهای محیطی: کلیدهای Stream API خود را به خود اضافه کنید
.env.local
فایل:NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key STREAM_API_SECRET=your_stream_api_secret
جایگزین کنید
your_stream_api_key
وyour_stream_api_secret
با کلیدهای داشبورد Stream شما. -
وارد کردن Stylesheets: Stream SDK ها دارای شیوه نامه های آماده برای اجزای چت و ویدیو هستند. این سبک ها را در خود وارد کنید
app/layout.tsx
فایل:// app/layout.tsx ... import '@stream-io/video-react-sdk/dist/css/styles.css'; import 'stream-chat-react/dist/css/v2/index.css'; import './globals.css'; ...
همگام سازی کارمند با برنامه استریم شما
برای اطمینان از سازگاری دادههای کاربر بین کارمند و جریان، باید یک وب هوکی راهاندازی کنید که اطلاعات کاربر را همگامسازی کند:
- راه اندازی ngrok: از آنجایی که وب هوک ها به یک URL در دسترس عموم نیاز دارند، ما از ngrok برای افشای سرور محلی خود استفاده خواهیم کرد. برای راه اندازی تونل ngrok برای برنامه خود مراحل زیر را دنبال کنید:
* 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).
- یک Webhook Endpoint در Clerk ایجاد کنید:
* **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.

-
Svix را نصب کنید: ما به Svix برای تأیید و رسیدگی به وب هوک های ورودی نیاز داریم. برای نصب پکیج دستور زیر را اجرا کنید:
npm install svix
-
Webhook Endpoint را در برنامه خود ایجاد کنید: در مرحله بعد، باید مسیری برای دریافت بار وب هوک ایجاد کنیم. ایجاد یک
/app/api/webhooks
دایرکتوری و a اضافه کنیدroute.ts
فایل با کد زیر:import { Webhook } from 'svix'; import { headers } from 'next/headers'; import { WebhookEvent } from '@clerk/nextjs/server'; import { StreamClient } from '@stream-io/node-sdk'; const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!; const SECRET = process.env.STREAM_API_SECRET!; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; export async function POST(req: Request) { const client = new StreamClient(API_KEY, SECRET); if (!WEBHOOK_SECRET) { throw new Error( 'Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local' ); } // Get the headers const headerPayload = headers(); const svix_id = headerPayload.get('svix-id'); const svix_timestamp = headerPayload.get('svix-timestamp'); const svix_signature = headerPayload.get('svix-signature'); // If there are no headers, error out if (!svix_id || !svix_timestamp || !svix_signature) { return new Response('Error occured -- no svix headers', { status: 400, }); } // Get the body const payload = await req.json(); const body = JSON.stringify(payload); // Create a new Svix instance with your secret. const wh = new Webhook(WEBHOOK_SECRET); let evt: WebhookEvent; // Verify the payload with the headers try { evt = wh.verify(body, { 'svix-id': svix_id, 'svix-timestamp': svix_timestamp, 'svix-signature': svix_signature, }) as WebhookEvent; } catch (err) { console.error('Error verifying webhook:', err); return new Response('Error occured', { status: 400, }); } const eventType = evt.type; switch (eventType) { case 'user.created': case 'user.updated': const newUser = evt.data; await client.upsertUsers([ { id: newUser.id, role: 'user', name: `${newUser.first_name} ${newUser.last_name}`, custom: { username: newUser.username, email: newUser.email_addresses[0].email_address, }, image: newUser.has_image ? newUser.image_url : undefined, }, ]); break; default: break; } return new Response('Webhook processed', { status: 200 }); }
در کنترل کننده وب هوک:
* 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`.
-
Webhook Endpoint را عمومی کنید: در نهایت، ما باید نقطه پایانی webhook را به مسیرهای عمومی در پیکربندی میانافزار اضافه کنیم تا اطمینان حاصل کنیم که Clerk میتواند از «خارج» به آن دسترسی داشته باشد. به سمت خود حرکت کنید
middleware.ts
فایل و موارد زیر را اضافه کنید:import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isPublicRoute = createRouteMatcher([ ... '/api/webhooks(.*)', ]); ...
پس از انجام این مراحل، استریم با موفقیت در برنامه شما راه اندازی می شود.
ساختن هاب فضای کاری
مرکز فضای کاری بخش مرکزی کلون Slack ما است که کاربران میتوانند در آن چت کنند، تماسهای ویدیویی برقرار کنند و فضای کاری خود را مدیریت کنند. همه ویژگیهای ضروری را ترکیب میکند – مانند نحوه سازماندهی کانالها و ابزارهای ارتباطی Slack – که ارتباط و کار با یکدیگر را برای کاربران آسان میکند.
ایجاد Layout
ما به یک چیدمان نیاز داریم که پایه و اساس تمام فعالیت ها در فضای کاری باشد. این چیدمان بخش هایی مانند نوار کناری، ناحیه چت و huddle را گرد هم می آورد.
ایجاد یک client
پوشه در app
دایرکتوری، و یک را اضافه کنید 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<Workspace>();
const [channel, setChannel] = useState<Channel>();
const [otherWorkspaces, setOtherWorkspaces] = useState<Workspace[]>([]);
const [chatClient, setChatClient] = useState<StreamChat>();
const [videoClient, setVideoClient] = useState<StreamVideoClient>();
const [channelCall, setChannelCall] = useState<Call>();
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 (
<div className="client font-lato w-screen h-screen flex flex-col">
<div className="absolute w-full h-full bg-theme-gradient" />
</div>
);
return (
<AppContext.Provider
value={{
workspace: workspace!,
setWorkspace,
otherWorkspaces,
setOtherWorkspaces,
channel: channel!,
setChannel,
loading,
setLoading,
chatClient,
setChatClient,
videoClient,
setVideoClient,
channelCall,
setChannelCall,
}}
>
<Chat client={chatClient}>
<StreamVideo client={videoClient}>
<div className="client font-lato w-screen h-screen flex flex-col">
<div className="absolute w-full h-full bg-theme-gradient" />
{/* Toolbar */}
<div className="relative w-full h-10 flex items-center justify-between pr-1">
<div className="w-[4.375rem] h-10 mr-auto flex-none" />
{!loading && (
<div className="flex flex-auto items-center">
<div className="relative hidden sm:flex flex-none basis-[24%]">
<div className="flex justify-start basis-full" />
<div className="flex justify-end basis-full mr-3">
<div className="flex gap-1 items-center">
<IconButton
icon={<ArrowBack color="var(--primary)" />}
disabled
/>
<IconButton
icon={<ArrowForward color="var(--primary)" />}
disabled
/>
</div>
<div className="flex items-center ml-1">
<IconButton icon={<Clock color="var(--primary)" />} />
</div>
</div>
</div>
<SearchBar placeholder={`Search ${workspace?.name}`} />
<div className="hidden sm:flex flex-[1_0_auto] items-center justify-end mr-1">
<IconButton icon={<Help color="var(--primary)" />} />
</div>
</div>
)}
</div>
{/* Main */}
<div className="w-screen h-[calc(100svh-40px)] grid grid-cols-[70px_auto]">
{/* Rail */}
<div className="relative w-[4.375rem] flex flex-col items-center gap-3 pt-2 z-[1000] bg-transparent">
{!loading && (
<>
<WorkspaceSwitcher />
<div className="relative flex flex-col items-center w-[3.25rem]">
<RailButton
title="Home"
icon={<Home color="var(--primary)" filled />}
active
/>
<RailButton
title="DMs"
icon={<Messages color="var(--primary)" />}
/>
<RailButton
title="Activity"
icon={<Notifications color="var(--primary)" />}
/>
<RailButton
title="Later"
icon={<Bookmark color="var(--primary)" />}
/>
<RailButton
title="More"
icon={<MoreHoriz color="var(--primary)" />}
/>
</div>
<div className="flex flex-col items-center gap-4 mt-auto pb-6 w-full">
<div className="cursor-pointer flex items-center justify-center w-9 h-9 rounded-full bg-[#565759]">
<Plus color="var(--primary)" />
</div>
<div className="relative h-9 w-9">
<UserButton />
<div className="absolute left-0 top-0 flex items-center justify-center pointer-events-none">
<div className="relative w-full h-full">
<Avatar
width={36}
borderRadius={8}
fontSize={20}
fontWeight={700}
data={{
name: user.fullName!,
image: user.imageUrl,
}}
/>
<span className="absolute w-3.5 h-3.5 rounded-full flex items-center justify-center -bottom-[3px] -right-[3px] bg-[#111215]">
<div className="w-[8.5px] h-[8.5px] rounded-full bg-[#3daa7c]" />
</span>
</div>
</div>
</div>
</div>
</>
)}
</div>
<WorkspaceLayout>{children}</WorkspaceLayout>
</div>
</div>
</StreamVideo>
</Chat>
</AppContext.Provider>
);
};
export default Layout;
چیزهای زیادی در اینجا می گذرد، بنابراین بیایید موارد را تجزیه کنیم:
-
مدیریت زمینه:
AppContext
اطلاعات به اشتراک گذاشته شده را در کل برنامه ذخیره می کند، مانند فضای کاری فعلی، کانال ها، کلاینت چت، سرویس گیرنده ویدیو و موارد دیگر. -
راه اندازی کلاینت های چت و ویدیو: در داخل
useEffect
، یک داریمsetUpChatAndVideo
عملکردی که کلاینت های چت و ویدیو را از استریم تنظیم می کند. کاربر را به کلاینت چت متصل می کند و کلاینت ویدیویی را برای تماس تنظیم می کند. -
ارائه دهنده توکن:
tokenProvider
تابع از ما یک نشانه می خواهد/api/token
نقطه پایانی این نشانه برای اینکه سرویس های Stream بدانند کاربر کیست مورد نیاز است. -
اجزای اصلی: طرح به بخش های اصلی مختلف تقسیم می شود:
- نوار ابزار: نوار ابزار دارای دکمه های پیمایش، نوار جستجو و دکمه راهنما است.
- راه آهن: این یک بخش عمودی با دکمههایی مانند “صفحه اصلی”، “DMs”، “Activity” و موارد دیگر است.
- WorkspaceSwitcher: این قسمت به کاربران اجازه می دهد بین فضاهای کاری جابجا شوند.
-
Workspace Layout:
WorkspaceLayout
شامل نوار کناری و محتوای کانال اصلی است.
افزودن مسیر API Token
در بخش آخر، یک ارائه دهنده توکن اضافه کردیم که درخواستی را به آن ارسال می کند /api/token
برای دریافت نشانه های کاربر Stream. در مرحله بعد، مسیر 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's Node SDK برای ایجاد توکن برای یک کاربر بر اساس کاربر استفاده می کنیم. userId
. این توکن کاربران را برای ویژگیهای چت و ویدیوی Stream احراز هویت میکند.
جزء سوئیچر فضای کاری
بعد، بیایید ایجاد کنیم WorkspaceSwitcher
مؤلفه ای که در بخش قبل به طرح خود اضافه کردیم.
ایجاد یک WorkspaceSwitcher.tsx
فایل در components
دایرکتوری و کد زیر را اضافه کنید:
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<HTMLDivElement>;
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 (
<div
onClick={() => setOpen((prev) => !prev)}
className="relative w-9 h-9 mb-[5px] cursor-pointer"
>
<Avatar
width={36}
borderRadius={8}
fontSize={20}
fontWeight={700}
data={{ name: workspace.name, image: workspace.image }}
/>
<div
ref={domNode}
className={clsx(
'z-[99] absolute top-11 -left-3 flex-col items-start text-channel-gray text-left w-[360px] rounded-xl overflow-hidden bg-[#212428] border border-[#797c8126] py-1',
open ? 'flex' : 'hidden'
)}
>
<div className="w-full px-4 py-2 text-[15px] leading-7 hover:bg-[#36383b]">
<div className="leading-[22px] font-bold truncate">
{workspace.name}
</div>
<div className="text-[13px] leading-[18px]">
{workspace.name.replace(/\s/g, '').toLowerCase()}.slack.com
</div>
</div>
<div className="w-full h-[1px] my-2 bg-[#797c8126]" />
<div className="flex flex-col text-[12.8px] leading-[1.38463] m-[4px_12px_4px_16px]">
<span className="font-bold">Never miss a notification</span>
<div>
<span className="cursor-pointer text-[#1D9BD1] hover:underline">
Get the Slack app
</span>{' '}
to see notifications from your other workspaces
</div>
</div>
<div className="w-full h-[1px] my-2 bg-[#797c8126]" />
{otherWorkspaces.map((otherWorkspace) => (
<button
key={otherWorkspace.id}
className="px-4 flex items-center w-full h-[52px] hover:bg-[#37393d] gap-3 text-[14.8px]"
onClick={() => switchWorkspace(otherWorkspace)}
>
<Avatar
width={36}
borderRadius={8}
fontSize={20}
fontWeight={700}
data={{ name: otherWorkspace.name, image: otherWorkspace.image }}
/>
<div className="flex flex-col text-left">
<div className="leading-[22px] font-bold truncate">
{otherWorkspace.name}
</div>
<div className="text-[13px] leading-[18px]">
{otherWorkspace.name.replace(/\s/g, '').toLowerCase()}.slack.com
</div>
</div>
</button>
))}
<button
className="px-4 flex items-center w-full h-[52px] hover:bg-[#37393d] gap-3 text-[14.8px]"
onClick={() => router.push(`/get-started`)}
>
<div className="w-9 h-9 flex items-center justify-center rounded-lg bg-[#f8f8f80f]">
<Plus color="var(--primary)" filled />
</div>
<div className="flex flex-col text-left text-white">
Add a workspace
</div>
</button>
</div>
</div>
);
};
export default WorkspaceSwitcher;
در WorkspaceSwitcher
جزء، زمانی که کاربر روی دکمه فضای کاری کلیک میکند، یک کشویی داریم. این کشویی به کاربران اجازه می دهد به راحتی بین فضاهای کاری جابجا شوند یا فضای جدیدی اضافه کنند.
-
جابجایی فضاهای کاری:
switchWorkspace
تابع فضای کاری و کانال فعلی را به روز می کند و سپس کاربر را به صفحه اصلی فضای کاری جدید هدایت می کند. -
روی Outside برای بستن کلیک کنید:
useClickOutside
قلاب برای بستن منوی کشویی تعویض کننده فضای کاری زمانی که کاربر در جایی خارج از آن کلیک می کند استفاده می شود. -
یک فضای کاری اضافه کنید: دکمه پایین به کاربران اجازه می دهد یک فضای کاری جدید ایجاد کنند و آنها را به صفحه راه اندازی هدایت کند.
ساخت مولفه چیدمان فضای کاری
بعد، ما ایجاد می کنیم WorkspaceLayout
مؤلفه ای که در بخش قبل اضافه کردیم، مشابه نحوه ایجاد آن WorkspaceSwitcher
جزء
ایجاد یک WorkspaceLayout.tsx
فایل در components
دایرکتوری و کد زیر را اضافه کنید:
'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<HTMLDivElement>(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 (
<div
ref={layoutRef}
className={clsx(
'relative flex mr-1 mb-1 rounded-md overflow-hidden border border-solid',
loading ? 'border-transparent' : 'border-[#797c814d]'
)}
>
{/* Sidebar */}
<Sidebar layoutWidth={layoutWidth} />
{layoutWidth > 0 && <div className="bg-[#1a1d21] grow">{children}</div>}
</div>
);
};
export default WorkspaceLayout;
را WorkspaceLayout
جزء یک ساختار ثابت برای کل فضای کاری فراهم می کند. شامل:
-
ادغام نوار کناری:
Sidebar
در این طرح بندی گنجانده شده است تا کاربران به راحتی به تمام کانال های فضای کاری دسترسی داشته باشند. -
عرض طرح: کامپوننت از a استفاده می کند
ResizeObserver
برای بدست آوردن عرض فعلی طرح و اطمینان از تغییر اندازه نوار کناری مناسب.
افزودن مولفه پیش نمایش کانال
بعد، ما ایجاد می کنیم ChannelPreview
جزء، که پیش نمایشی از هر کانال را در فضای کاری نشان می دهد.
ایجاد یک ChannelPreview.tsx
فایل در components
دایرکتوری و کد زیر را اضافه کنید:
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 (
<SidebarButton
icon={Hash}
title={displayTitle}
onClick={goToChannel}
active={channelActive()}
boldText={Boolean(unread)}
/>
);
};
export default ChannelPreview;
در کد بالا:
-
پیش نمایش کانال:
ChannelPreview
جزء هر کانال را در نوار کناری نشان می دهد. کاربران می توانند روی یک کانال کلیک کنند تا با استفاده از آن، آن را باز کنندgoToChannel
تابع، که به کانال انتخاب شده هدایت می شود. -
متن پررنگ برای پیام های خوانده نشده: اگر پیامهای خواندهنشده در کانالی وجود داشته باشد، نام کانال با متن پررنگ نشان داده میشود و به آسانی کاربران میتوانند ببینند کدام کانالها نیاز به توجه دارند.
-
برجسته کانال فعال:
channelActive
تابع بررسی می کند که آیا کانال فعلی فعال است یا خیر و آن را در نوار کناری برجسته می کند تا کاربران بدانند در حال حاضر در کدام کانال هستند.
افزودن نوار کناری
عملکرد اولیه از Sidebar
جزء این است که به کاربران دسترسی سریع به کانال ها بدهد.
ایجاد یک Sidebar.tsx
فایل در components
دایرکتوری و کد زیر را اضافه کنید:
'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<number>(() => {
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 (
<div
id="sidebar"
style={{ width: `${width}px` }}
className={clsx(
'hidden relative px-2 sm:flex flex-col flex-shrink-0 gap-3 min-w-0 min-h-0 max-h-[calc(100svh-44px)] bg-[#10121499] border-r-[1px] border-solid',
loading ? 'border-r-transparent' : 'border-r-[#797c814d]'
)}
>
{!loading && (
<>
<div className="pl-1 w-full h-[49px] flex items-center justify-between">
<div className="max-w-[calc(100%-80px)]">
<button className="w-fit max-w-full rounded-md py-[3px] px-2 flex items-center text-white hover:bg-hover-gray">
<span className="truncate text-[18px] font-[900] leading-[1.33334]">
{workspace.name}
</span>
<div className="flex-shrink-0">
<CaretDown size={18} color="var(--primary)" />
</div>
</button>
</div>
<div className="flex ">
<IconButton
icon={
<Refine className="fill-icon-gray group-hover:fill-white" />
}
className="w-9 h-9 hover:bg-hover-gray"
/>
<IconButton
icon={
<Compose className="fill-icon-gray group-hover:fill-white" />
}
className="w-9 h-9 hover:bg-hover-gray"
/>
</div>
</div>
<div className="w-full flex flex-col">
<SidebarButton icon={Threads} iconSize="lg" title="Threads" />
<SidebarButton icon={Send} iconSize="lg" title="Drafts & sent" />
</div>
<div className="w-full flex flex-col">
<div className="h-7 -ml-1.5 flex items-center px-4 text-[15px] leading-7">
<button className="hover:bg-hover-gray rounded-md">
<ArrowDropdown color="var(--icon-gray)" />
</button>
<button className="flex px-[5px] max-w-full rounded-md text-sidebar-gray font-medium hover:bg-hover-gray">
Channels
</button>
</div>
<ChannelList
filters={{ workspaceId: workspace.id }}
Preview={ChannelPreview}
sort={{
created_at: 1,
}}
LoadingIndicator={() => null}
lockChannelOrder
/>
</div>
{/* Handle */}
<div
className="absolute -right-1 w-2 h-full bg-transparent cursor-col-resize"
onMouseDown={() => {
isDragged.current = true;
}}
/>
</>
)}
</div>
);
};
export default Sidebar;
در کد بالا:
-
نوار کناری قابل تغییر اندازه:
Sidebar
کاربر می تواند اندازه آن را تغییر دهد و به او اجازه می دهد تا عرض را هر طور که دوست دارد تنظیم کند. -
لیست کانال:
ChannelList
ازstream-chat-react
تمام کانال های موجود در فضای کاری را نشان می دهد. لیست را می توان فیلتر و مرتب کرد و به کاربران کمک می کند کانال های مورد نیاز خود را سریع پیدا کنند.
بعد، استایل های زیر را به آن اضافه کنید globals.css
برای تغییر استایل پیش فرض ChannelList
:
...
@layer components {
#sidebar
.str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {
background: none;
border: none;
}
#sidebar
.str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react
> div {
padding: 0;
}
}
ایجاد مسیر API Workspace
برای واکشی داده های فضای کاری، به یک مسیر API نیاز داریم که اطلاعات فضای کاری را برمی گرداند.
ایجاد یک route.ts
فایل در a /api/workspaces/[workspaceId]
دایرکتوری و کد زیر را اضافه کنید:
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import prisma from '@/lib/prisma';
export async function GET(
_: Request,
{ params }: { params: Promise<{ workspaceId: string }> }
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const workspaceId = (await params).workspaceId;
if (!workspaceId || Array.isArray(workspaceId)) {
return NextResponse.json(
{ error: 'Invalid workspace ID' },
{ status: 400 }
);
}
try {
// Check if the user is a member of the workspace
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId,
workspaceId,
},
},
});
if (!membership) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
// Fetch the workspace along with related data
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
include: {
channels: true,
memberships: true,
invitations: {
where: { acceptedAt: null },
},
},
});
if (!workspace) {
return NextResponse.json(
{ error: 'Workspace not found' },
{ status: 404 }
);
}
// Fetch the other workspaces the user is a member of excluding the current workspace
const otherWorkspaces = await prisma.workspace.findMany({
where: {
memberships: {
some: {
userId,
workspaceId: { not: workspaceId },
},
},
},
include: {
channels: true,
memberships: true,
invitations: {
where: { acceptedAt: null },
},
},
});
return NextResponse.json({ workspace, otherWorkspaces }, { status: 200 });
} catch (error) {
console.error('Error fetching workspace:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
این مسیر رسیدگی می کند GET
درخواستهای واکشی دادههای فضای کاری بر اساس شناسه فضای کاری ارائه شده در URL:
-
احراز هویت: مسیر ابتدا بررسی می کند که آیا کاربر با تأیید جلسه خود احراز هویت شده است یا خیر.
-
اعتبار سنجی عضویت: همچنین قبل از بازگرداندن داده ها، بررسی می کند که آیا کاربر عضوی از فضای کاری درخواستی است یا خیر.
-
بازیابی داده ها: اگر کاربر مجاز باشد، مسیر، فضای کاری، کانالها و دادههای عضویت را به همراه هر دعوتنامه در انتظاری از پایگاه داده بازیابی میکند.
ساخت صفحه کانال
صفحه کانال کانال فعلی را در یک فضای کاری خاص نمایش می دهد. از چندین قلاب و زمینه برای اطمینان از بارگیری و نمایش صحیح همه اطلاعات کانال استفاده می کند.
ایجاد یک /client/[workspaceId]/[channelId]/
دایرکتوری و الف page.tsx
فایل با اضافه کردن کد زیر:
'use client';
import { useContext, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { Channel as ChannelType } from 'stream-chat';
import { DefaultStreamChatGenerics } from 'stream-chat-react';
import { StreamCall, useCalls } from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import { AppContext } from '../../layout';
import CaretDown from '@/components/icons/CaretDown';
import Files from '@/components/icons/Files';
import Hash from '@/components/icons/Hash';
import Headphones from '@/components/icons/Headphones';
import Message from '@/components/icons/Message';
import MoreVert from '@/components/icons/MoreVert';
import Pin from '@/components/icons/Pin';
import Plus from '@/components/icons/Plus';
import User from '@/components/icons/User';
interface ChannelProps {
params: {
workspaceId: string;
channelId: string;
};
}
const Channel = ({ params }: ChannelProps) => {
const { workspaceId, channelId } = params;
const router = useRouter();
const { user } = useUser();
const [currentCall] = useCalls();
const {
chatClient,
loading,
setLoading,
workspace,
setWorkspace,
setOtherWorkspaces,
channel,
setChannel,
channelCall,
setChannelCall,
videoClient,
} = useContext(AppContext);
const [chatChannel, setChatChannel] =
useState<ChannelType<DefaultStreamChatGenerics>>();
const [channelLoading, setChannelLoading] = useState(true);
const [pageWidth, setPageWidth] = useState(0);
const layoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (loading || !layoutRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setPageWidth(entry.contentRect.width);
}
});
resizeObserver.observe(layoutRef.current);
return () => {
resizeObserver.disconnect();
};
}, [layoutRef, loading]);
useEffect(() => {
const loadWorkspace = async () => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`);
const result = await response.json();
if (response.ok) {
setWorkspace(result.workspace);
setOtherWorkspaces(result.otherWorkspaces);
localStorage.setItem(
'activitySession',
JSON.stringify({ workspaceId, channelId })
);
setLoading(false);
} else {
console.error('Error fetching workspace data:', result.error);
router.push('/');
}
} catch (error) {
console.error('Error fetching workspace data:', error);
router.push('/');
}
};
const loadChannel = async () => {
const currentMembers = workspace.memberships.map((m) => m.userId);
const chatChannel = chatClient.channel('messaging', channelId, {
members: currentMembers,
name: channel.name,
description: channel.description,
workspaceId: channel.workspaceId,
});
await chatChannel.create();
if (currentCall?.id === channelId) {
setChannelCall(currentCall);
} else {
const channelCall = videoClient?.call('default', channelId);
setChannelCall(channelCall);
}
setChatChannel(chatChannel);
setChannelLoading(false);
};
const loadWorkspaceAndChannel = async () => {
if (!workspace) {
await loadWorkspace();
} else {
if (!channel)
setChannel(workspace.channels.find((c) => c.id === channelId)!);
if (loading) setLoading(false);
if (chatClient && channel) loadChannel();
}
};
if ((!chatChannel || chatChannel?.id !== channelId) && user)
loadWorkspaceAndChannel();
}, [
channel,
channelId,
chatChannel,
chatClient,
currentCall,
loading,
router,
setChannel,
setChannelCall,
setLoading,
setOtherWorkspaces,
setWorkspace,
user,
videoClient,
workspace,
workspaceId,
]);
useEffect(() => {
if (currentCall?.id === channelId) {
setChannelCall(currentCall);
}
}, [currentCall, channelId, setChannelCall]);
if (loading) return null;
return (
<div
ref={layoutRef}
className="channel bg-[#1a1d21] font-lato w-full h-full z-100 flex flex-col overflow-hidden text-channel-gray"
>
{/* Toolbar */}
<div className="pl-4 pr-3 h-[49px] flex items-center flex-shrink-0 justify-between">
<div className="flex flex-[1_1_0] items-center min-w-0">
<button className="min-w-[96px] px-2 py-[3px] -ml-1 mr-2 flex flex-[0_auto] items-center text-[17.8px] rounded-md text-channel-gray hover:bg-[#d1d2d30b] leading-[1.33334]">
<span className="mr-1 align-text-bottom">
<Hash color="var(--channel-gray)" size={18} />
</span>
<span className="truncate font-[900]">{channel?.name}</span>
</button>
<div
className={clsx(
'w-[96px] flex-[1_1_0] min-w-[96px] mr-2 pt-1 text-[12.8px] text-[#e8e8e8b3]',
pageWidth > 0 && pageWidth < 500 ? 'hidden' : 'flex'
)}
>
<span className="min-w-[96px] max-w-[min(70%,540px)] truncate">
{channel?.description}
</span>
</div>
</div>
<div className="flex flex-none ml-auto items-center">
<button
className={clsx(
'flex items-center pl-2 py-[3px] rounded-lg h-7 border border-[#797c814d] text-[#e8e8e8b3] hover:bg-[#25272b]',
pageWidth > 0 && pageWidth < 605 ? 'hidden' : 'flex'
)}
>
<User color="var(--icon-gray)" />
<span className="pl-1 pr-2 text-[12.8px]">
{workspace.memberships.length}
</span>
</button>
<button className="group rounded-lg flex w-7 h-7 ml-2 items-center justify-center hover:bg-[#d1d2d30b]">
<MoreVert className="fill-[#e8e8e8b3] group-hover:fill-channel-gray" />
</button>
</div>
</div>
{/* Tab Bar */}
<div className="w-full min-w-full max-w-full h-[38px] flex items-center pl-4 pr-3 shadow-[inset_0_-1px_0_0_#797c814d] gap-1">
<div className="flex items-center cursor-pointer w-[92.45px] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray border-b-[2px] border-white">
<Message color="var(--primary)" />
Messages
</div>
<div className="group flex items-center cursor-pointer text-[#b9babd] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray hover:text-white">
<Files className="fill-icon-gray group-hover:fill-white" size={16} />
Files
</div>
<div className="group flex items-center cursor-pointer text-[#b9babd] h-full p-2 gap-1 text-[13px] leading-[1.38463] text-center font-bold rounded-t-lg hover:bg-hover-gray hover:text-white">
<Pin className="fill-icon-gray group-hover:fill-white" size={16} />
Pins
</div>
<div className="group flex items-center justify-center cursor-pointer h-7 w-7 rounded-full hover:bg-hover-gray">
<Plus
filled
className="fill-icon-gray group-hover:fill-white"
size={16}
/>
</div>
</div>
{/* Chat */}
<div className="relative flex flex-col w-full h-full flex-1 overflow-hidden ">
{/* Body */}
<div className="relative flex-1">
<div className="absolute -top-2 bottom-0 flex w-full overflow-hidden">
<div
style={{
width: pageWidth > 0 ? pageWidth : '100%',
}}
className="relative"
>
<div className="absolute h-full inset-[0_-50px_0_0] overflow-y-scroll overflow-x-hidden z-[2]">
{/* Messages */}
<div>Hello World!</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="relative max-h-[calc(100%-36px)] flex flex-col -mt-2 px-5">
<div id="message-input" className="flex-1"></div>
<div className="w-full flex items-center h-6 pl-3 pr-2"></div>
</div>
</div>
</div>
);
};
export default Channel;
بیایید این را تجزیه کنیم:
-
مدیریت چیدمان: کامپوننت از
layoutRef
وResizeObserver
برای مدیریت و تنظیم طرح صفحه به صورت پویا بر اساس عرض بخش کانال. -
در حال بارگذاری کانال: کامپوننت ابتدا بررسی می کند که آیا فضای کاری و اطلاعات کانال در دسترس هستند یا خیر، و در غیر این صورت، یک فراخوانی API برای بارگیری داده ها برقرار می کند.
-
جلسه فعالیت ذخیره سازی: پس از بارگذاری داده های فضای کاری، جلسه فعالیت را در آن ذخیره می کنیم
localStorage
. این جلسه شاملworkspaceId
وchannelId
برای به خاطر سپردن آخرین فضای کاری و کانال فعال کاربر. -
مشتریان چت و ویدیو: ما کلاینتهای چت و ویدیو را راهاندازی میکنیم تا امکان پیامرسانی و تماس همزمان در کانال را فراهم کنیم.
-
نوار ابزار و پاورقی: نوار ابزار جزئیات کانال فعلی، مانند نام و توضیحات آن را نشان می دهد، در حالی که پاورقی حاوی یک ناحیه ورودی برای ارسال پیام است.
راه اندازی صفحه مشتری
مؤلفه Client یک صفحه ابزاری است که کاربران را به آخرین فضای کاری و کانال فعال خود هدایت می کند. این کار را با بررسی انجام می دهد activitySession
ذخیره شده در localStorage
. اگر هیچ جلسه فعالیتی یافت نشد، کاربر به صفحه اصلی هدایت می شود.
ایجاد یک page.tsx
فایل در /app/client
دایرکتوری با کد زیر:
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Client() {
const router = useRouter();
useEffect(() => {
const fetchActivitySession = async () => {
const activitySession = localStorage.getItem('activitySession');
if (activitySession) {
const { workspaceId, channelId } = await JSON.parse(activitySession);
router.push(`/client/${workspaceId}/${channelId}`);
} else {
router.push('/');
}
};
fetchActivitySession();
}, [router]);
return null;
}
ساخت صفحه فضای کاری
در نهایت، ما یک صفحه ابزار دوم ایجاد خواهیم کرد که منطق هدایت کاربر به یک کانال در یک فضای کاری را مدیریت می کند.
ایجاد یک page.tsx
فایل در /client/[workspaceId]
دایرکتوری:
'use client';
import { useContext, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AppContext, Workspace } from '../layout';
interface WorkspacePageProps {
params: {
workspaceId: string;
};
}
export default function WorkspacePage({ params }: WorkspacePageProps) {
const { workspaceId } = params;
const { workspace, setWorkspace, setOtherWorkspaces } =
useContext(AppContext);
const router = useRouter();
useEffect(() => {
const goToChannel = (workspace: Workspace) => {
const channelId = workspace.channels[0].id;
localStorage.setItem(
'activitySession',
JSON.stringify({ workspaceId: workspace.id, channelId })
);
router.push(`/client/${workspace.id}/${channelId}`);
};
const loadWorkspace = async () => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`);
const result = await response.json();
if (response.ok) {
setWorkspace(result.workspace);
setOtherWorkspaces(result.otherWorkspaces);
goToChannel(result.workspace);
} else {
console.error('Error fetching workspace data:', result.error);
}
} catch (error) {
console.error('Error fetching workspace data:', error);
}
};
if (!workspace) {
loadWorkspace();
} else {
goToChannel(workspace);
}
}, [workspace, workspaceId, setWorkspace, setOtherWorkspaces, router]);
return null;
}
در کد بالا، اگر دادههای فضای کاری هنوز بارگذاری نشدهاند، آن را از روی واکشی میکنیم /api/workspaces/[workspaceId]
مسیر، و کاربر را به اولین کانال موجود در آن فضای کاری هدایت کنید.
و با آن، ما اکنون یک پایه محکم برای کلون Slack خود داریم!
نتیجه گیری
در این بخش اول ساخت کلون Slack، ما:
-
پروژه را راه اندازی کنید، از جمله ایجاد فضای کاری، مدیریت کانال و ادغام با Stream و Clerk.
-
مسیرهای API را برای مدیریت فضاهای کاری و کانال ها ایجاد کرد.
-
اجزای ضروری برای پیمایش بین فضاهای کاری و کانال ها ساخته شده است.
در قسمت بعدی به پیاده سازی پیام رسانی بلادرنگ و مدیریت کانال ها خواهیم پرداخت.
با ما همراه باشید!