برنامه نویسی

ساخت 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 برای برنامه خود ایجاد کنید:

  1. به داشبورد بروید و روی “کلیک کنید”ایجاد اپلیکیشن“.

  2. نام برنامه خود را “کلون شل“.

  3. زیر “گزینه های ورود به سیستم،” انتخاب کنید ایمیل، نام کاربری، و گوگل.

  4. روی ” کلیک کنیدایجاد اپلیکیشن” برای تکمیل تنظیمات.

مراحل داشبورد منشی

پس از ایجاد پروژه، صفحه نمای کلی برنامه را مشاهده خواهید کرد که حاوی شماست کلید قابل انتشار و کلید مخفی– اینها را در دسترس داشته باشید زیرا بعداً به آنها نیاز خواهید داشت.

تنظیمات نام و نام خانوادگی کارمند

در مرحله بعد، فیلدهای نام و نام خانوادگی را در هنگام ثبت نام ایجاد می کنیم:

  1. به داشبورد خود بروید “پیکربندی” برگه

  2. تحت “کاربر و احراز هویت“، انتخاب کنید”ایمیل، تلفن، نام کاربری“.

  3. پیدا کردن “نام“گزینه در”اطلاعات شخصیبخش ” و آن را روشن کنید..

  4. روی نماد چرخ دنده در کنار ” کلیک کنیدنام” و آن را به عنوان مورد نیاز تنظیم کنید.

  5. کلیک کنید ”ادامه دهید” برای ذخیره تغییرات شما.

نصب کارمند در پروژه شما

سپس، بیایید Clerk را به پروژه Next.js خود اضافه کنیم:

  1. بسته Clerk را با اجرای دستور زیر نصب کنید:

    npm install @clerk/nextjs
    
  2. ایجاد کنید .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 با کلیدهای صفحه نمای کلی پروژه شما.

  3. برای استفاده از احراز هویت 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 راه اندازی کنیم و اجزاء این مؤلفه‌ها با رابط کاربری داخلی همراه هستند و تمام منطق احراز هویت را مدیریت می‌کنند.

در اینجا نحوه اضافه کردن صفحات آمده است:

  1. URL های احراز هویت را تنظیم کنید: منشی و اجزا باید بدانند در کجای برنامه شما نصب شده اند. این مسیرها را به مسیر خود اضافه کنید .env.local فایل:

    NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
    NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
    
  2. صفحه ثبت نام را ایجاد کنید: ایجاد یک صفحه ثبت نام در 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>
      );
    }
    
  3. صفحه ورود به سیستم را ایجاد کنید: ایجاد یک 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>
      );
    }
    
  4. میان افزار کارمند خود را اضافه کنید: منشی با الف می آید 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 و صفحه راه‌اندازی را به پایان رسانده‌اید، زمان آن رسیده است که اولین فضای کاری خود را ایجاد کنید تا مطمئن شوید همه چیز مطابق انتظار کار می‌کند. این مراحل را دنبال کنید:

  1. به صفحه تنظیمات بروید: به برنامه خود بروید /get-started صفحه

  2. مشخصات لازم را پر کنید: نام فضای کاری، نام کانال و آدرس ایمیل اعضایی را که می خواهید دعوت کنید وارد کنید.

  3. افزودن تصویر (اختیاری): در صورت تمایل، یک URL تصویر اضافه کنید تا فضای کاری شخصی تر به نظر برسد.

  4. فرم را ارسال کنید: برای ایجاد فضای کاری روی دکمه «ارسال» کلیک کنید.

  5. ایجاد را تأیید کنید: مطمئن شوید که فضای کاری با موفقیت ایجاد شده است و همه اعضای دعوت شده دعوت نامه دریافت می کنند.

  6. داشبورد را بررسی کنید: بررسی کنید که فضای کاری جدید به درستی در داشبورد شما فهرست شده باشد و کانال اولیه قابل مشاهده باشد.

ایجاد فضای کاری جدید

با دنبال کردن این مراحل، می توانید تأیید کنید که جریان ایجاد فضای کاری به درستی کار می کند.

راه اندازی استریم در برنامه شما

استریم چیست؟

Stream پلتفرمی است که به توسعه دهندگان اجازه می دهد تا ویژگی های چت و ویدیوی غنی را به برنامه های خود اضافه کنند. به جای پرداختن به پیچیدگی ایجاد چت و ویدیو از پایه، استریم API و SDK را ارائه می‌کند تا به شما کمک کند آنها را سریع و آسان اضافه کنید.

در این پروژه، ما از React SDK Stream برای ویدیو و React Chat SDK برای ساخت ویژگی‌های چت و تماس ویدیویی در Slack clone خود استفاده خواهیم کرد.

ایجاد حساب جریانی شما

صفحه ثبت نام جریانی

برای شروع استفاده از Stream، باید یک حساب کاربری ایجاد کنید:

  1. ثبت نام کنید: به صفحه ثبت نام Stream بروید و با استفاده از ایمیل یا ورود به سیستم اجتماعی یک حساب کاربری ایجاد کنید.

  2. نمایه خود را تکمیل کنید:

* 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.

    ![Strem sign up options](https://cdn.hashnode.com/res/hashnode/image/upload/v1726664432078/966254af-e0b3-4a54-b395-52667e6374b7.png)

* Click **"Complete Signup"** to continue.
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون به داشبورد استریم خود هدایت خواهید شد.

ایجاد یک پروژه جریان جدید

فرم برنامه جدید ایجاد کنید

پس از ایجاد اکانت استریم، مرحله بعدی این است که یک برنامه برای پروژه خود راه اندازی کنید:

  1. یک برنامه جدید ایجاد کنید: در داشبورد Stream خود، روی ” کلیک کنیدایجاد اپلیکیشندکمه “

  2. برنامه خود را پیکربندی کنید:

* **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.
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  1. کلیدهای API خود را دریافت کنید: پس از ایجاد برنامه، به مسیر “کلیدهای دسترسی به برنامهبخش “. برای اتصال Stream به پروژه خود به این کلیدها نیاز دارید.

    کلیدهای API جریانی

پیکربندی مجوزهای کاربر

برای اجازه دادن به کاربران برای ارسال پیام، خواندن کانال ها و انجام سایر اقدامات، باید مجوزهای لازم را در داشبورد Stream تنظیم کنید:

به روز رسانی نقش ها و مجوزها

  1. به “نقش ها و مجوزها“برگه زیر”چت پیام

  2. انتخاب کنید “کاربر“نقش و انتخاب”پیام رسانی” دامنه

  3. روی ” کلیک کنیدویرایش کنید” را فشار دهید و مجوزهای زیر را انتخاب کنید:

* Create Message

* Read Channel

* Read Channel Members

* Create Reaction

* Upload Attachments

* Create Attachments
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  1. تغییرات را ذخیره و تایید کنید.

نصب Stream SDK

برای شروع استفاده از Stream در پروژه Next.js، باید چند SDK نصب کنیم:

  1. SDK ها را نصب کنید: برای نصب بسته های لازم دستور زیر را اجرا کنید:

    npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat
    
  2. تنظیم متغیرهای محیطی: کلیدهای 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 شما.

  3. وارد کردن 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';
    ...
    

همگام سازی کارمند با برنامه استریم شما

برای اطمینان از سازگاری داده‌های کاربر بین کارمند و جریان، باید یک وب هوکی راه‌اندازی کنید که اطلاعات کاربر را همگام‌سازی کند:

  1. راه اندازی 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).
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  1. یک 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.

    ![Signing secret](https://cdn.hashnode.com/res/hashnode/image/upload/v1726741867125/7c6ffd89-36ac-4c4b-a5e0-bd665796612d.png)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  1. Svix را نصب کنید: ما به Svix برای تأیید و رسیدگی به وب هوک های ورودی نیاز داریم. برای نصب پکیج دستور زیر را اجرا کنید:

    npm install svix
    
  2. 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`.
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  1. 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 را برای مدیریت فضاهای کاری و کانال ها ایجاد کرد.

  • اجزای ضروری برای پیمایش بین فضاهای کاری و کانال ها ساخته شده است.

در قسمت بعدی به پیاده سازی پیام رسانی بلادرنگ و مدیریت کانال ها خواهیم پرداخت.

با ما همراه باشید!

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا