AI [Перевод] Создаём свой Telegram-клон с помощью Next.js и TailwindCSS — Часть 1

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 814
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1


Разработка чат-приложения с нуля может показаться довольно сложной задачей. Но при наличии правильных инструментов все становится намного проще, чем вы думаете.

В этой серии из трех частей мы подробно рассмотрим процесс создания клона веб-версии Telegram с использованием Next.js, TailwindCSS и Stream SDK. В первой части мы настроим все необходимые инструменты для нашего проекта, добавим аутентификацию и создадим макет приложения с помощью TailwindCSS.

Во второй части мы сосредоточимся на разработке диалоговой секции нашего пользовательского интерфейса и добавлении обмена сообщениями в режиме реального времени с помощью Stream React Chat SDK. Наконец, в третьей части мы добавим видео- и аудиовызовы в наше приложение, используя Stream React Video and Audio SDK.

К концу этой серии у вас будет полное понимание того, как работают современные чат-приложения, а также полностью функциональный проект, который вы сможете использовать в своей дальнейшей работе.

Вот как будет выглядеть конечный результат:

View: https://youtu.be/I6EnQlVdMpc


Вы можете попробовать в действии рабочее демо проекта и найти его код в этом GitHub-репозитории.

Что ж, давайте приступим!

Предварительные требования


Чтобы извлечь максимальную пользу из этого руководства, прежде чем мы начнем, убедитесь, что вы знакомы со следующими концепциями:


  • Основы React: Вы должны знать, как создавать компоненты, управлять состоянием и работать с компонентной архитектурой React.


  • Node.js & npm: Убедитесь, что у вас установлены Node.js и npm, поскольку они необходимы для запуска и сборки нашего проекта.


  • Основы TypeScript, Next.js и TailwindCSS: Мы будем активно использовать эти технологии, поэтому, имея базовые знания о них, вам будет легче разобраться в этом руководстве.
Подготовка проекта


Давайте начнем с подготовки проекта. Мы будем использовать стартовый шаблон, содержащий весь шаблонный код, который нам нужен для начала работы.

Чтобы клонировать стартовый шаблон, выполните следующие команды:

git clone https://github.com/TropicolX/telegram-clone.git
cd telegram-clone
git checkout starter
npm install

После выполнения этих команд структура вашего проекта должна выглядеть следующим образом:



Этот шаблон содержит наш Next.js сетап с предварительно настроенными TypeScript и TailwindCSS. Он также включает в себя другие базовые модули и каталоги, которые мы будем использовать в этом руководстве, в том числе:


  • components: Здесь мы будем хранить все наши повторно используемые компоненты.


  • hooks: В этой папке будут содержаться все наши пользовательские React-хуки.


  • lib: Эта папка содержит файл utils.ts, который мы используем для хранения служебных функций.
Аутентификация пользователя с помощью Clerk


Чтобы использовать приложение Telegram Web, пользователи должны войти в систему. Поэтому мы тоже добавим аутентификацию в наш клон и будем использовать для этого Clerk.

Что такое Clerk?


Clerk — это платформа для управления пользователями. Она предоставляет широкий набор инструментов для аутентификации и профилей пользователей, включающий компоненты пользовательского интерфейса, API и панель мониторинга для администраторов.

Этот инструмент значительно упростит добавление функций аутентификации в наш Telegram-клон.

Создание учетной записи Clerk


Clerk sign-up page

Чтобы начать работу с Clerk, вам необходимо создать учетную запись на их веб-сайте. Для этого перейдите на страницу регистрации Clerk и зарегистрируйтесь, используя свой адрес электронной почты или какой-нибудь доступный социальный аккаунт.

Создание проекта Clerk



После входа в систему вам нужно будет создать проект вашего приложения. Для этого выполните следующие шаги:


  1. Перейдите на панель управления и нажмите "Create application".


  2. Назовите свое приложение “Telegram clone”.


  3. В разделе “Sign in options” выберите Email, Username, и Google.


  4. Нажмите "Create application", чтобы завершить процесс настройки.

Clerk dashboard steps

Как только проект будет создан, вы получите доступ к странице обзора приложения. Здесь вы найдете свои Publishable Key и Secret Key — обязательно сохраните их, они понадобятся вам в дальнейшем.



Теперь нам нужно сделать так, чтобы пользователь в процессе регистрации мог ввести свои имя и фамилию, подобно тому, как это происходит в Telegram. Вы можете активировать эту функцию, выполнив следующие шаги:


  1. Перейдите на вкладку "Configure" на своей панели управления.


  2. Найдите опцию "Name" в разделе "Personal Information" и включите ее.


  3. Нажмите на значок шестеренки рядом с полем "Name" и настройте его по своему усмотрению.


  4. Нажмите “Continue”, чтобы сохранить изменения.
Установка Clerk в вашем проекте


Далее давайте установим Clerk в ваш Next.js проект. Для этого выполните следующие шаги:


  1. Чтобы установить Next.js SDK 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 ключами со страницы обзора вашего проекта Clerk.

3.Чтобы иметь доступ к пользовательским данным и аутентификации во всем приложении, необходимо обернуть наш основной макет в компонент <ClerkProvider /> Clerk.Для этого откройте файл 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="h-svh w-svw lg:h-screen lg:w-screen antialiased text-color-text select-none overflow-hidden">
{children}
</body>
</html>
</ClerkProvider>
);
}
Создание страниц регистрации и логина


Следующим шагом в разработке нашего Telegram-клона станет создание страниц регистрации и логина. Для этого мы будем использовать компоненты Clerk <SignUp /> и <SignIn />. Эти компоненты включают в себя все элементы пользовательского интерфейса и логику аутентификации, которые нам понадобятся.

Чтобы добавить эти страницы в ваше приложение, выполните следующие шаги:

1.Настройте URL-адреса аутентификации: Компоненты Clerk <SignUp /> и <SignIn /> требуют, чтобы мы указали, где они расположены в нашем приложении. Мы можем сделать это с помощью переменных окружения. Добавьте следующие маршруты в ваш .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 py-4 bg-background 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-background flex items-center justify-center">
<SignIn />
</div>
);
}

4.Добавьте Clerk Middleware: Следующим шагом мы создадим вспомогательное middleware для настройки наших защищенных маршрутов. Наша цель — сделать доступными для всех пользователей только маршруты регистрации, закрыв все остальные. Для этого создайте в каталоге src файл middleware.ts со следующим кодом:

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: [
// Пропускаем внутренние файлы Next.js и все статические файлы, если они не найдены в параметрах поиска

'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Всегда запускается для маршрутов API
'/(api|trpc)(.*)',
],
};


После выполнения этих шагов Clerk будет интегрирован в ваше приложение вместе со страницами входа и регистрации.

Настройка Stream

What is Stream?

Что такое Stream?



Stream — это платформа, которая позволяет разработчикам легко интегрировать расширенные функции чата и видео в свои приложения. Вместо того чтобы самостоятельно разрабатывать эти функции с нуля, Stream предлагает API и SDK, которые значительно упрощают процесс.

Мы будем использовать React SDK for Video и React Chat SDK Stream для реализации функций чата и видеозвонков в нашем Telegram-клоне.

Создание учетной записи Stream



Давайте начнем с создания учетной записи Stream:


  1. Регистрация: Перейдите на страницу регистрации Stream и создайте новую учетную запись, используя свой адрес электронной почты или логин в социальной сети.


  2. Заполните свой профиль:

    • После регистрации вас попросят предоставить дополнительную информацию, например, о вашем роде деятельности и отрасли.


    • Выберите опции "Chat Messaging" и "Video and Audio", поскольку нам нужны эти инструменты для нашего приложения.


      Strem sign up options

    • Наконец, нажмите "Complete Signup", чтобы продолжить.

После выполнения описанных выше шагов вы будете перенаправлены на панель управления Stream.

Создание нового проекта Stream



Теперь вам необходимо настроить Stream-приложение для вашего проекта:


  1. Создайте новое приложение: В правом верхнем углу панели управления Stream нажмите "Create App".


  2. Настройте свое приложение:

    • App Name: Введите подходящее имя, например "the-telegram-clone", или любое другое имя по вашему выбору.


    • Region: Для оптимальной производительности рекомендуется выбрать ближайший к вам регион.


    • Environment: Оставьте в этой опции значение "Development".


    • Нажмите кнопку "Create App", чтобы завершить настройку.

  3. Получите ключи API: После создания приложения перейдите в раздел "App Access Keys". Эти ключи понадобятся вам для подключения Stream к вашему проекту.

Установка Stream SDK


Чтобы начать использовать Stream в нашем Next.js проекте, нам понадобится установить несколько пакетов SDK:

1.Установите Stream SDK: Для установки необходимых пакетов выполните следующую команду:

npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat

2.Добавьте ключи приложения Stream: Добавьте ключи API Stream в свой .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 на ключи, которые вы получили в разделе "App Access Keys" на панели управления Stream.

3.Импортируйте таблицы стилей: Пакеты Stream @stream-io/video-react-sdk and stream-chat-react включают CSS таблицы для своих компонентов. Импортируйте CSS @stream-io/video-react-sdk в ваш файл app/layout.tsx:https://getstream.io/video/docs/react/ui-components/video-theme/#importing-the-css

...
import '@stream-io/video-react-sdk/dist/css/styles.css';
import './globals.scss';
...

Затем импортируйте стили stream-chat-react в свой файл globals.scss:

...
@import "~stream-chat-react/dist/scss/v2/index.scss";
...
Создание исходного макета



После успешной установки Clerk и Stream мы готовы приступить к созданию главной страницы нашего Telegram-клона.

Первым шагом станет разработка общего макета нашего приложения. Этот макет будет включать все настройки, необходимые для отображения чата и видеоданных Stream во всем нашем приложении. На нем также будет боковая панель, на которой мы разместим список чатов пользователя.

Для начала создайте новую папку a в каталоге app и добавьте туда файл layout.tsx со следующим содержимым:

'use client';
import { ReactNode, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { StreamChat } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import { StreamVideo, StreamVideoClient } from '@stream-io/video-react-sdk';
import clsx from 'clsx';

interface LayoutProps {
children?: ReactNode;
}

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;
export const [minWidth, defaultWidth, defaultMaxWidth] = [256, 420, 424];
export default function Layout({ children }: LayoutProps) {
const { user } = useUser();
const { channelId } = useParams<{ channelId?: string }>();
const [loading, setLoading] = useState(true);
const [chatClient, setChatClient] = useState<StreamChat>();
const [videoClient, setVideoClient] = useState<StreamVideoClient>();
const [sidebarWidth, setSidebarWidth] = useState(0);

useEffect(() => {
const savedWidth =
parseInt(localStorage.getItem('sidebarWidth') as string) || defaultWidth;
localStorage.setItem('sidebarWidth', String(savedWidth));
setSidebarWidth(savedWidth);
}, []);

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.hasImage ? clerkUser.imageUrl : undefined,
custom: {
username: clerkUser.username,
},
};

if (!chatClient.user) {
await chatClient.connectUser(chatUser, customProvider);
}

setChatClient(chatClient);
const videoClient = StreamVideoClient.getOrCreateInstance({
apiKey: API_KEY,
user: chatUser,
tokenProvider: customProvider,
});
setVideoClient(videoClient);
setLoading(false);
};

if (user) setUpChatAndVideo();
}, [user, videoClient, chatClient]);

if (loading)
return (
<div className="flex h-full w-full">
<div
style={{
width: ${sidebarWidth || defaultWidth}px,
}}
className="bg-background h-full flex-shrink-0 relative"
></div>
<div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">
<div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>
</div>
</div>
);
return (
<Chat client={chatClient!}>
<StreamVideo client={videoClient!}>
<div className="flex h-full w-full">
<div
className={clsx(
'fixed max-w-none left-0 right-0 top-0 bottom-0 lg:relative flex w-full h-full justify-center z-[1] min-w-0',
!channelId &&
'translate-x-[100vw] min-[601px]:translate-x-[26.5rem] lg:translate-x-0'
)}
>
<div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">
<div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>
{children}
</div>
</div>
</div>
</StreamVideo>
</Chat>
);
}

В этом файле много важных вещей, поэтому давайте рассмотрим их все по порядку:


  • Поставщик токенов: Мы используем функцию tokenProvider, которая извлекает токен из конечной точки /api/token. Этот токен необходим сервисам Stream для идентификации пользователя.


  • Настройка чата и видео: Мы определяем setUpChatAndVideo внутри useEffect для подключения пользователя к чат- и видео-клиенту Stream. Мы получаем данные пользователя от Clerk и передаем их обоим клиентам вместе с токеном от нашего поставщика токенов.


  • Управление шириной боковой панели: Мы сохраняем ширину боковой панели (sidebarWidth) в localStorage. Когда компонент монтируется, мы загружаем это значение, чтобы боковая панель всегда соответствовала заданному пользователем размеру.


  • Общий макет:

    • Если мы все еще загружаем пользователя или что-то монтируем, мы создаем макет-заполнитель.


    • Как только все будет готово, мы оборачиваем все в компоненты <Chat> и <StreamVideo>.


    • Боковая панель (левая область), которая представляет собой отдельный раздел, и основное содержимое (правая область), которая содержит чат, передаются макету в качестве дочерних элементов.
Создание маршрута API для токена


Теперь давайте создадим маршрут для конечной точки /api/token, которую мы уже упоминали ранее.

Создайте в каталоге 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);
}

Этот код отвечает за генерацию и возврат токена аутентификации для пользователя на основе предоставленного userId.

Настройка перенаправления и структура каналов


Каждый чат в нашем Telegram-клоне будет обрабатываться в рамках отдельного канала. В Stream каждый канал включает в себя:


  • Сообщения, которыми обмениваются пользователи.


  • Список людей, следящих за каналом (активных участников).


  • Опциональный список участников (для личных бесед).

Поскольку каждый канал имеет уникальный ID, нашей главной точкой входа станет маршрут /a/[channelId], по которому пользователи будут взаимодействовать с чатом. Однако по умолчанию посещение / не приводит на главную страницу приложения, поэтому нам нужно позаботиться о перенаправлении пользователей в нужное место.

Структура маршрутизации в нашем приложении будет выглядеть следующим образом:


  1. Мы будем перенаправлять из корневой страницы (/) в /a.


  2. Создадим страницу-заглушку в /a из соображений чистоты архитектуры.


  3. Настроим /a/[channelId], где и будет располагаться чат.
Перенаправление с корневой страницы


Прежде всего, добавим следующий код в файл /app/page.tsx:

import { redirect } from 'next/navigation';

export default function Home() {
redirect('/a');
}

Таким образом при посещении / пользователи автоматически перенаправляются на /a, где будут обрабатываться наши каналы.

Создание страницы-заглушки


Затем создайте файл page.tsx внутри каталога /app/a/ и добавьте туда следующий код:

const Main = () => {
return null;
};

export default Main;

Этот компонент не будет ничего отображать, он просто служит заглушкой для поддержания чистоты нашей структуры маршрутизации.

Создание страницы канала


Теперь создадим главную страницу чата. Создайте внутри каталога /app/a/ папку с именем [channelId] и добавьте туда файл page.tsx со следующим кодом:

'use client';

import { useParams } from 'next/navigation';

const Chat = () => {
const { channelId } = useParams<{ channelId: string }>();
return <div>{channelId}</div>;
};

export default Chat;

Этот компонент извлекает channelId из URL и отображает его. Позже мы будем использовать этот ID для загрузки корректных данных чата из Stream.



Благодаря этому сетапу наша маршрутизация теперь структурирована должным образом.

Отображение списка каналов на боковой панели


В этом разделе мы добавим в наше приложение компонент боковой панели. Боковая панель позволит пользователям просматривать свои активные чаты, искать разговоры и инициировать новые. Для реализации этой задачи мы будем использовать компонент Stream channelList. Мы настроим его стиль и функции, чтобы он максимально соответствовал Telegram.

Создание компонента папки с чатами


Первый компонент, который мы создадим для нашей боковой панели, — это компонент ChatFolders. Этот компонент будет содержать ChannelList, строку поиска и меню профиля Clerk для нашего приложения.

Создайте в каталоге components новый файл под названием ChatFolders.tsx и добавьте в него следующий код:

import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import {
ChannelList,
ChannelSearchProps,
useChatContext,
} from 'stream-chat-react';

import ChatPreview from './ChatPreview';
import SearchBar from './SearchBar';
import Spinner from './Spinner';

const ChatFolders = ({}: ChannelSearchProps) => {
const { user } = useUser();
const { client } = useChatContext();
const router = useRouter();

return (
<div className="flex-1 overflow-hidden relative w-full h-[calc(100%-3.5rem)]">
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="flex-1 overflow-hidden relative w-full h-full">
<div className="w-full h-full">
<div className="custom-scroll p-2 overflow-y-scroll overflow-x-hidden h-full bg-background pe-2 min-[820px]:pe-[0px]">
<ChannelList
Preview={ChatPreview}
sort={{
last_message_at: -1,
}}
filters={{
members: { $in: [client.userID!] },
}}
showChannelSearch
additionalChannelSearchProps={{
searchForChannels: true,
onSelectResult: async (_, result) => {
if (result.cid) {
router.push(/a/${result.id});
} else {
const channel = client.getChannelByMembers('messaging', {
members: [user!.id, result.id!],
});
await channel.create();
router.push(/a/${channel.data?.id});
}
},
SearchBar: SearchBar,
}}
LoadingIndicator={() => (
<div className="w-full h-full flex items-center justify-center">
<div className="relative w-12 h-12">
<Spinner color="var(--color-primary)" />
</div>
</div>
)}
/>
</div>
</div>
</div>
</div>
</div>
);
};

export default ChatFolders;

Давайте разберем некоторые ключевые моменты этого компонента:


  • Контекст чата и пользователь:

    • Мы получаем текущего пользователя (user) из Clerk с помощью useUser().


    • client извлекается из useChatContext(), которая предоставляет доступ к функциям чата Stream.

  • Список каналов и поиск:

    • Мы используем ChannelList из Stream для отображения каналов чатов пользователя, отсортированных по последнему сообщению.


    • Чтобы отображались только те каналы, участником которых является текущий пользователь, мы применяем фильтр (members: { $in: [client.userId!] }).


    • Панель поиска (SearchBar) позволяет пользователям искать каналы и других пользователей.

  • Выбор или создание чата:

    • Если результатом поиска является канал (cid существует), мы переходим к нему.


    • В противном случае, если это другой пользователь, мы создаем с ним новый чат один на один, используя getChannelByMembers, а затем перенаправляем пользователя в новый чат.

  • Индикатор загрузки: Во время загрузки мы показываем отцентрированный Spinner в области списка чатов.

Теперь нам необходимо создать компоненты ChatPreview и Searchbar, которые мы уже импортировали в наш код.

Отображение превью чатов


Каждый канал на боковой панели должен отображать превью, содержащее название чата, последнее сообщение, время и количество непрочитанных сообщений. Мы реализуем это с помощью компонента ChatPreview.

Создайте в каталоге components новый файл под именем ChatPreview.tsx и добавьте туда следующий код:

import { useCallback, useMemo } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
ChannelPreviewUIComponentProps,
useChatContext,
} from 'stream-chat-react';
import clsx from 'clsx';

import Avatar from './Avatar';

const ChatPreview = ({
channel,
displayTitle,
unread,
displayImage,
lastMessage,
}: ChannelPreviewUIComponentProps) => {
const { client } = useChatContext();
const router = useRouter();
const pathname = usePathname();

const isDMChannel = channel.id?.startsWith('!members');
const goToChat = () => {
const channelId = channel.id;
router.push(/a/${channelId});
};

const getDMUser = useCallback(() => {
const members = { ...channel.state.members };
delete members[client.userID!];
return Object.values(members)[0].user!;
}, [channel.state.members, client.userID]);

const getChatName = useCallback(() => {
if (displayTitle) return displayTitle;
else {
const member = getDMUser();
return member.name || ${member.first_name} ${member.last_name};
}
}, [displayTitle, getDMUser]);

const getImage = useCallback(() => {
if (displayImage) return displayImage;
else if (isDMChannel) {
const member = getDMUser();
return member.image;
}
}, [displayImage, getDMUser, isDMChannel]);

const lastText = useMemo(() => {
if (lastMessage) {
return lastMessage.text;
}

if (isDMChannel) {
return ${getChatName()} joined Telegram;
} else {
return `${
// @ts-expect-error one of these will be defined
channel.data?.created_by?.first_name ||
// @ts-expect-error one of these will be defined
channel.data?.created_by?.name.split(' ')[0]
} created the group "${displayTitle}"`;
}
}, [
lastMessage,
channel.data?.created_by,
getChatName,
displayTitle,
isDMChannel,
]);
const lastMessageDate = useMemo(() => {
const date = new Date(
lastMessage?.created_at || (channel.data?.created_at as string)
);
const today = new Date();
if (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
) {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: false,
});
} else if (date.getFullYear() === today.getFullYear()) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
}, [lastMessage, channel.data?.created_at]);

const active = useMemo(() => {
const pathChannelId = pathname.split('/').filter(Boolean).pop();
return pathChannelId === channel.id;
}, [pathname, channel.id]);

return (
<div
className={clsx(
'relative p-[.5625rem] cursor-pointer min-h-auto overflow-hidden flex items-center rounded-xl whitespace-nowrap gap-2',
active && 'bg-chat-active text-white',
!active && 'bg-background text-color-text hover:bg-chat-hover'
)}
onClick={goToChat}
>
<div className="relative">
<Avatar
data={{
name: getChatName(),
image: getImage(),
}}
width={54}
/>
</div>
<div className="flex-1 overflow-hidden">
<div className="flex items-center justify-start overflow-hidden">
<div className="flex items-center justify-start overflow-hidden gap-1">
<h3 className="font-semibold truncate text-base">
{getChatName()}
</h3>
</div>
<div className="grow min-w-2" />
<div className="flex items-center shrink-0 mr-[.1875rem] text-[.75rem]">
<span className={active ? 'text-white' : 'text-color-text-meta'}>
{lastMessageDate}
</span>
</div>
</div>
<div className="flex items-center justify-start truncate">
<p
className={clsx(
'truncate text-[.9375rem] text-left pr-1 grow',
active && 'text-white',
!active && 'text-color-text-secondary'
)}
>
{lastText}
</p>
{unread !== undefined && unread > 0 && (
<div
className={clsx(
'min-w-6 h-6 shrink-0 rounded-xl text-sm leading-6 text-center py-0 px-[.4375rem] font-medium',
active && 'bg-white text-primary',
!active && 'bg-green text-white'
)}
>
<span className="inline-flex whitespace-pre">{unread}</span>
</div>
)}
</div>
</div>
</div>
);
};

export default ChatPreview;

В приведенном выше коде:


  • Получение информации о пользователе и чате:

    • Мы получаем чат-клиент (client) смомощью хука useChatContext().


    • Функция getDMUser() определяет другого участника в личном чате, не учитывая текущего пользователя.

  • Отображение информацию о чате:

    • Функция getChatName() извлекает отображаемое имя для групповых чатов или имя другого участника для личных чатов.


    • Функция getImage() извлекает аватар чата.

  • Форматирование последнего сообщения и времени:

    • Последнее сообщение отображается, если оно существует. В противном случае отображается дефолтное системное сообщение (например, “Пользователь присоединился к Telegram” или “Группа создана”).


    • Функция lastMessageDate обеспечивает корректное форматирование времени:

      • Если сообщение отправлено сегодня, оно отображается в формате HH:MM.


      • Если оно было отправлено в течение этого года, то отображается месяц и дата.


      • В противном случае отображается только год.

  • Переход к чатам:

    • Когда пользователь нажимает на чат, он перенаправляется на него с помощью router.push().

  • Выделение чата:

    • Если текущий чат соответствует указанному URL, превью чата подсвечивается, чтобы показать, что он активен.


    • Если в чате есть непрочитанные сообщения, отображается зеленый значок с их количеством.
Добавление панели поиска


Панель поиска позволит пользователям выполнять поиск чатов и каналов. Она также будет содержать меню профиля для пользователей.

Создайте внутри каталога components новый файл под именем SearchBar.tsx и добавьте в него следующий код:

import { UserButton, useUser } from '@clerk/nextjs';
import { SearchBarProps } from 'stream-chat-react';

import RippleButton from './RippleButton';

const SearchBar = ({ exitSearch, onSearch, query }: SearchBarProps) => {
const { user } = useUser();

const handleClick = () => {
if (query) {
exitSearch();
}
};

return (
<div className="flex items-center bg-background px-[.8125rem] pt-1.5 pb-2 gap-[.625rem] h-[56px]">
<div className="relative h-10 w-10 [&>div:first-child]">
<div className="[&>div]:opacity-0">
{user && !query && <UserButton />}
</div>
<div className="absolute left-0 top-0 flex items-center justify-center pointer-events-none">
<RippleButton
onClick={handleClick}
icon={query ? 'arrow-left' : 'menu'}
/>
</div>
</div>
<div className="relative w-full bg-chat-hover text-[rgba(var(--color-text-secondary-rgb),0.5)] max-w-[calc(100%-3.25rem)] border-[2px] border-chat-hover has-[:focus]:border-primary has-[:focus]:bg-background rounded-[1.375rem] flex items-center pe-[.1875rem] transition-opacity ease-[cubic-bezier(0.33,1,0.68,1)] duration-[330ms]">
<input
type="text"
name="Search"
value={query}
onChange={onSearch}
placeholder="Search"
autoComplete="off"
className="peer order-2 h-10 text-black rounded-[1.375rem] bg-transparent pl-[11px] pt-[6px] pb-[7px] pr-[9px] focus:outline-none focus:caret-primary"
/>
<div className="w-6 h-6 ms-3 shrink-0 flex items-center justify-center peer-focus:text-primary">
<i className="icon icon-search text-2xl leading-[1]" />
</div>
</div>
</div>
);
};

export default SearchBar;

Давайте подробно рассмотрим этот компонент:


  • Отображение и кнопка меню:

    • Если пользователь авторизован и поисковый запрос отсутствует, отображается кнопка Clerk UserButton.


    • RippleButton динамически переключается между иконкой меню и стрелкой назад в зависимости от состояния поиска.

  • Обработка поиска и динамический стиль:

    • Когда пользователь вводит текст, onSearch обновляет запрос и фильтрует результаты.


    • Клик по стрелке назад завершает поиск (exitSearch()).
Создание компонента боковой панели


Теперь, когда все подкомпоненты готовы, давайте объединим их в нашей боковой панели.

Создайте файл Sidebar.tsx в каталоге components и добавьте в него следующий код:

'use client';
import React, { useState, useEffect, RefObject } from 'react';
import clsx from 'clsx';

import Button from './Button';
import ChatFolders from './ChatFolders';
import { minWidth, defaultMaxWidth } from '@/app/a/layout';
import useClickOutside from '@/hooks/useClickOutside';

enum SidebarView {
Default,
NewGroup,
}

interface SidebarProps {
width: number;
setWidth: React.Dispatch<React.SetStateAction<number>>;
}

export default function Sidebar({ width, setWidth }: SidebarProps) {
const getMaxWidth = () => {
const windowWidth = window.innerWidth;
let newMaxWidth = defaultMaxWidth;

if (windowWidth >= 1276) {
newMaxWidth = Math.floor(windowWidth * 0.33);
} else if (windowWidth >= 926) {
newMaxWidth = Math.floor(windowWidth * 0.4);
}
return newMaxWidth;
};

const [maxWidth, setMaxWidth] = useState(getMaxWidth());
const [menuOpen, setMenuOpen] = useState(false);
const [view, setView] = useState(SidebarView.Default);

const menuDomNode = useClickOutside(() => {
setMenuOpen(false);
}) as RefObject<HTMLDivElement>;

const toggleMenu = () => {
setMenuOpen((prev) => !prev);
};

const openNewGroupView = () => {
setView(SidebarView.NewGroup);
setMenuOpen(false);
};

useEffect(() => {
const calculateMaxWidth = () => {
const newMaxWidth = getMaxWidth();
setMaxWidth(newMaxWidth);
setWidth(width >= newMaxWidth ? newMaxWidth : width);
};

calculateMaxWidth();

window.addEventListener('resize', calculateMaxWidth);

return () => {
window.removeEventListener('resize', calculateMaxWidth);
};
}, [setWidth, width]);

useEffect(() => {
if (width) {
let newWidth = width;
if (width > maxWidth) {
newWidth = maxWidth;
}
setWidth(newWidth);
localStorage.setItem('sidebarWidth', String(width));
}
}, [width, maxWidth, setWidth]);

// Обработчик изменения размера боковой панели
const handleResize = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
const startX = event.clientX;
const startWidth = width;

const onMouseMove = (e: MouseEvent) => {
const newWidth = Math.min(
Math.max(minWidth, startWidth + (e.clientX - startX)),
maxWidth
);
setWidth(newWidth);
};

const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};

return (
<div
id="sidebar"
style={{ width: ${width}px }}
className="max-[600px]:!w-full max-[925px]:!w-[26.5rem] w-auto group bg-background h-full flex-shrink-0 relative"
onMouseLeave={() => setMenuOpen(false)}
>
{/* Вид по умолчанию */}
<div
className={clsx(
'contents',
view === SidebarView.Default ? 'block' : 'hidden'
)}
>
<ChatFolders />
</div>
{/* Кнопка создания нового чата */}
<div
className={clsx(
'absolute right-4 bottom-4 translate-y-20 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover:translate-y-0',
menuOpen && 'translate-y-0',
view === SidebarView.NewGroup && 'hidden'
)}
>
<Button
active
icon="new-chat-filled"
onClick={toggleMenu}
className={clsx('sidebar-button', menuOpen ? 'active' : '')}
>
<i className="absolute icon icon-close" />
</Button>
<div>
{menuOpen && (
<div className="fixed left-[-100vw] right-[-100vw] top-[-100vh] bottom-[-100vh] z-20" />
)}
<div
ref={menuDomNode}
className={clsx(
'bg-background-compact-menu backdrop-blur-[10px] custom-scroll py-1 bottom-[calc(100%+0.5rem)] right-0 origin-bottom-right overflow-hiddden list-none absolute shadow-[0_.25rem_.5rem_.125rem_#72727240] rounded-xl min-w-[13.5rem] z-[21] overscroll-contain text-black transition-[opacity,_transform] duration-150 ease-[cubic-bezier(0.2,0.0.2,1)]',
menuOpen
? 'block opacity-100 scale-100'
: 'hidden opacity-0 scale-[.85]'
)}
>
<div
onClick={openNewGroupView}
className="text-sm my-[.125rem] mx-1 p-1 pe-3 rounded-md font-medium scale-100 transition-transform duration-150 ease-in-out bg-transparent flex items-center relative overflow-hidden leading-6 whitespace-nowrap text-black cursor-pointer"
>
<i
className="icon icon-group max-w-5 text-[1.25rem] me-5 ms-2 text-[#707579]"
aria-hidden="true"
/>
{'New Group'}
</div>
</div>
</div>
</div>
{/* Изменение размера */}
<div
className="hidden lg:block absolute z-20 top-0 -right-1 h-full w-2 cursor-ew-resize"
onMouseDown={handleResize}
/>
</div>
);
}

В приведенном выше компоненте:


  • Управление шириной боковой панели:

    • Ширина боковой панели динамически рассчитывается на основе размера окна и настраивается в определенном диапазоне (от minWidth до maxWidth).


    • Ширина сохраняется в localStorage для сохранения между сеансами.

  • Изменение размера боковой панели:

    • cursor-ew-resize позволяет пользователям перетаскивать боковую панель для изменения размера.


    • Функция handleResize гарантирует, что ширина остается в допустимых пределах при перетаскивании.

  • Отображение боковой панели:

    • Боковая панель имеет два режима:

      1. Вид по умолчанию – отображает список чатов (ChatFolders).


      2. Просмотр новой группы – открывается, когда пользователь нажимает кнопку "New Group".

  • Переключение меню и клик вне компонента:

    • При нажатии кнопки "New Chat" открывается плавающее меню.


    • Если пользователь кликает за пределами меню, оно автоматически закрывается (useClickOutside()).
Добавление стилей боковой панели


Теперь давайте оформим нашу боковую панель, чтобы она хорошо сочеталась с остальным пользовательским интерфейсом чата.

Откройте файл globals.scss в каталоге app и добавьте туда следующий CSS-код:

...
#sidebar .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {
background: none;
border: none;
box-shadow: none;
}

#sidebar
.str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react>div {
padding: 0;
}

#sidebar
.str-chat__channel-search {
position: absolute;
width: 100%;
top: 0;
left: 0;
}

#sidebar .str-chat__channel-list-react .str-chat__channel-list-messenger-react {
margin-top: 56px;
}

#sidebar .str-chat__channel-search-result-list.inline {
padding: 0.5rem;
}

#sidebar .str-chat__channel-search-result {
border-radius: 0.75rem;
}
...
Добавление боковой панели в макет


Наконец, давайте добавим боковую панель в макет нашего приложения.

Откройте файл /a/layout.tsx и добавьте туда следующий код:

...
import Sidebar from '@/components/Sidebar';
...

export default function Layout({ children }: LayoutProps) {
...
return (
<Chat client={chatClient!}>
<StreamVideo client={videoClient!}>
<div className="flex h-full w-full">
<Sidebar width={sidebarWidth} setWidth={setSidebarWidth} />
...
</div>
</StreamVideo>
</Chat>
);
}

Здесь мы импортируем компонент Sidebar, включаем его в макет и и предоставляем значения для его пропов width и setWidth, чтобы можно было динамически изменять размер.


Создание группового чата на боковой панели


Теперь, когда у нас есть боковая панель, следующим шагом станет реализация функции создания групповых чатов. Для этого мы создадим на боковой панели представление, которое позволит пользователям:


  • Давать название групповому чату


  • Выбирать доступных пользователей в приложении


  • Создавать новый канал чата с выбранными пользователями

Создайте в каталоге components новый файл под именем NewGroupView.tsx и добавьте туда следующий код:

import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { DefaultStreamChatGenerics, useChatContext } from 'stream-chat-react';
import { UserResponse } from 'stream-chat';

import Avatar from './Avatar';
import Button from './Button';
import RippleButton from './RippleButton';
import Spinner from './Spinner';
import { customAlphabet } from 'nanoid';
import { getLastSeen } from '../lib/utils';
import clsx from 'clsx';

interface NewGroupViewProps {
goBack: () => void;
}

const NewGroupView = ({ goBack }: NewGroupViewProps) => {
const { client } = useChatContext();

const [creatingGroup, setCreatingGroup] = useState(false);
const [query, setQuery] = useState('');
const [groupName, setGroupName] = useState('');
const [users, setUsers] = useState<UserResponse<DefaultStreamChatGenerics>[]>(
[]
);
const [originalUsers, setOriginalUsers] = useState<
UserResponse<DefaultStreamChatGenerics>[]
>([]);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);

const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
const cancelled = useRef(false);

useEffect(() => {
const getAllUsers = async () => {
const userId = client.userID;
const { users } = await client.queryUsers(
// @ts-expect-error - id
{ id: { $ne: userId } },
{ id: 1, name: 1 },
{ limit: 20 }
);

setUsers(users);
setOriginalUsers(users);
};
getAllUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleUserSearch = async (e: ChangeEvent<HTMLInputElement>) => {
const query = e.target.value.trim();
setQuery(query);
if (!query) {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
cancelled.current = true;
setUsers(originalUsers);
return;
}

cancelled.current = false;

if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(async () => {
if (cancelled.current) return;

try {
const userId = client.userID;
const { users } = await client.queryUsers(
{
$or: [
{ id: { $autocomplete: query } },
{ name: { $autocomplete: query } },
],
// @ts-expect-error - id
id: { $ne: userId },
},
{ id: 1, name: 1 },
{ limit: 5 }
);

if (!cancelled.current) setUsers(users);
} catch (error) {
console.error('Error fetching users:', error);
}
}, 200);
};

const leave = () => {
setCreatingGroup(false);
setGroupName('');
setQuery('');
setSelectedUsers([]);
goBack();
};

const createNewGroup = async () => {
if (!groupName) {
alert('Please enter a group name.');
return;
}
if (selectedUsers.length < 2) {
alert('Please select at least two users.');
return;
}

setCreatingGroup(true);

try {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
const nanoid = customAlphabet(alphabet, 7);
const group = client.channel('messaging', nanoid(7), {
name: groupName,
members: [...selectedUsers, client.userID!],
});

await group.create();
leave();
} catch (error) {
console.error(error);
alert('Error creating group');
} finally {
setCreatingGroup(false);
}
};

const onSelectUser = (e: ChangeEvent<HTMLInputElement>) => {
const userId = e.target.id;
setSelectedUsers((prevSelectedUsers) => {
if (prevSelectedUsers.includes(userId)) {
return prevSelectedUsers.filter((id) => id !== userId);
} else {
return [...prevSelectedUsers, userId];
}
});
};

const sortedUsers = useMemo(
() =>
users.sort((a, b) => {
if (selectedUsers.includes(a.id)) {
return -1;
} else if (selectedUsers.includes(b.id)) {
return 1;
} else {
return 0;
}
}),
[users, selectedUsers]
);

return (

<>
<div className="flex items-center bg-background px-[.8125rem] pt-1.5 pb-2 gap-[1.375rem] h-[56px]">
<RippleButton onClick={leave} icon="arrow-left" />
<h3 className="text-[1.25rem] font-medium mr-auto select-none truncate">
New Group
</h3>
</div>
<div className="flex flex-col px-5 h-[calc(100%-3.5rem)] overflow-hidden">
<div>
<label
htmlFor="groupName"
className="relative block mt-5 py-[11px] px-[18px] rounded-xl border border-color-borders-input shadow-sm focus-within:border-primary focus-within:ring-1 focus-within:ring-primary"
>
<input
type="text"
id="groupName"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="peer caret-primary border-none bg-transparent placeholder-transparent placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"
placeholder="Group name"
/>
<span className="pointer-events-none absolute start-[18px] top-0 -translate-y-1/2 bg-white p-0.5 text-sm text-[#a2acb4] transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-focus:top-0 peer-focus:text-xs peer-focus:text-primary">
Group name
</span>
</label>
<h3 className="my-4 mx-1 font-medium text-[1rem] text-color-text-secondary">
Add members
</h3>
<label
htmlFor="user"
className="relative caret-primary block overflow-hidden border-b border-color-borders-input bg-transparent py-3 px-5 focus-within:border-primary"
>
<input
type="text"
id="users"
placeholder="Who would you like to add?"
value={query}
onChange={(e) => handleUserSearch(e)}
className="text-base h-8 w-full border-none bg-transparent p-0 placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"
/>
</label>
<fieldset className="flex flex-col gap-2 mt-2 custom-scroll">
{sortedUsers.map((user) => (
<UserCheckbox
key={user.id}
user={user}
checked={selectedUsers.includes(user.id)}
onChange={onSelectUser}
/>
))}
</fieldset>
</div>
</div>
<div className="absolute right-4 bottom-4 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] translate-y-0">
<Button
active
icon="arrow-right"
onClick={createNewGroup}
disabled={creatingGroup}
className={clsx('sidebar-button', creatingGroup ? 'active' : '')}
>
<div className="icon-loading absolute">
<div className="relative w-6 h-6 before:relative before:content-none before:block before:pt-full">
<Spinner />
</div>
</div>
</Button>
</div>
</>
);
};

interface UserCheckboxProps {
user: UserResponse<DefaultStreamChatGenerics>;
checked: boolean;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

const UserCheckbox = ({ user, checked, onChange }: UserCheckboxProps) => {

return (
<label
htmlFor={user.id}
className="flex items-center gap-2 p-2 h-[3.5rem] rounded-xl hover:bg-chat-hover bg-background-compact-menu cursor-pointer"
>
<div className="relative h-10 w-10">
<Avatar
data={{
name: user.name || ${user.first_name} ${user.last_name},
image: user.image || '',
}}
width={40}
/>
</div>
<div>
<p className="text-base leading-5">
{user.name || ${user.first_name} ${user.last_name}}
</p>
<p className="text-sm text-color-text-meta">
{getLastSeen(user.last_active!)}
</p>
</div>
<div className="flex items-center ml-auto">
&#8203;
<input
id={user.id}
type="checkbox"
checked={checked}
onChange={onChange}
className="size-4 rounded border-2 border-color-borders-input"
/>
</div>
</label>
);
};

export default NewGroupView;


Давайте рассмотрим ключевые функции этого компонента:


  • Извлечение пользователей:

    • Компонент извлекает список пользователей (исключая текущего пользователя) с помощью client.queryUsers().


    • Список хранится в users и originalUsers, чтобы обеспечить фильтрацию при поиске.

  • Поиск пользователей:

    • Функция handleUserSearch динамически фильтрует пользователей на основе входных данных.


    • Debounce-механизм предотвращает избыточные вызовы API.

  • Процесс создания группы:

    • Пользователь выбирает участников с помощью чекбоксов.


    • Прежде чем продолжить, необходимо указать название группы.


    • При создании группы с помощью nanoid() генерируется случайный ID и создается новый канал чата.

  • Пользовательский интерфейс и взаимодействие:

    • Поля ввода: Ввод названия группы и панель поиска пользователя с динамическими метками.


    • Выбор участников: Пользователи сортируются в соответствии с их приоритетом, при этом выбранные участники отображаются вверху списка.


    • Состояния кнопки:

      • Кнопка "Create" отключается при создании группы.


      • Во время загрузки появляется индикатор загрузки.

  • Возврат и сброс состояния:

    • Нажатие кнопки отмены сбрасывает форму и удаляет выбранных пользователей.

Далее, мы добавим на боковую панель компонент NewGroupView, чтобы пользователи могли легко получить доступ к функции создания групп.

Для этого перейдите в файл /components/Sidebar.tsx и внесите следующие изменения, добавив в него NewGroupView:

...
import NewGroupView from './NewGroupView';
...

export default function Sidebar({ width, setWidth }: SidebarProps) {
...

return (
<div
id="sidebar"
...
>
{/* Вид по умолчанию */}
...
{/* Создание новой группы */}
<div
className={clsx(
'contents',
view === SidebarView.NewGroup ? 'block' : 'hidden'
)}
>
<NewGroupView goBack={() => setView(SidebarView.Default)} />
</div>
{/* Кнопка создания нового чата */}
...
{/* Изменение размера */}
...
</div>
);
}

Здесь мы добавили новый компонент под названием NewGroupView, который теперь отображается в боковой панели в зависимости от действий пользователя. Когда пользователь нажимает на кнопку “New Group”, боковая панель меняет свой вид на NewGroupView. После создания группы или отмены операции боковая панель возвращается к своему первоначальному виду.



Вот и все! С этим обновлением пользователи теперь могут создавать новые группы прямо на боковой панели.

Заключение


В этой части серии мы заложили фундамент для нашего клона веб-версии Telegram, настроив проект Next.js, интегрировав аутентификацию Clerk и создав базовый макет с помощью TailwindCSS. Мы также установили Stream SDK и добавили возможность создавать групповые чаты.

В следующей части мы сосредоточимся на создании интерфейса чата и реализации обмена сообщениями в режиме реального времени с помощью Stream React Chat SDK.

Продолжение следует…


Хотите создавать такие же современные веб-приложения, как Telegram-клон на Next.js и TailwindCSS?

Тогда вам точно стоит обратить внимание на курс JavaScript Developer. Basic, который стартует 26 июня.

А чтобы вы точно поняли, как все устроено — мы подготовили два бесплатных открытых урока, которые напрямую связаны с темами этой статьи:

 
Сверху Снизу