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

Недавно нам нужно было добавить чат в админку одного из проектов. Забавно, но решения вроде Jivo или LiveChat мы даже не рассматривали. Так были уверены, что сможем без проблем собрать кастомный чат прямо внутри Admiral.

Эксперимент завершился удачно и теперь мы хотим поделиться его результатами. В этой статье мы предлагаем вам готовый туториал по интеграции сложной функциональности на примере real-time чата.

Почему Admiral идеален для кастомных решений

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

Поэтому разработка чата – отличный пример того, как Admiral позволяет выйти за рамки стандартных CRUD-операций.

Познакомиться с Admiral можно здесь: https://github.com/dev-family/admiral.

Архитектура чата

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

  • ChatPage – основная страница чата;

  • ChatSidebar – список диалогов с превью;

  • ChatPanel – область отображения выбранного чата;

  • MessageFeed – лента сообщений;

  • MessageInput – поле ввода с поддержкой файлов.

А также контексты управления:

  • SocketContext – управление WebSocket соединениями;

  • ChatContext – управление состоянием диалогов и сообщений.

Основная страница чата

Admiral использует файловую систему роутинга, что делает создание новых страниц максимально простым:

// pages/chat/index.tsx

import ChatPage from '@/src/crud/chat'
export default ChatPage

Всё! Страница автоматически доступна по адресу /chat. Никаких дополнительных настроек роутера не требуется.

Теперь создадим основную логику в src/crud/chat/index.tsx

// src/crud/chat/index.tsx

import React from 'react'

import { Card } from '@devfamily/admiral'
import { usePermissions, usePermissionsRedirect } from '@devfamily/admiral'
import { SocketProvider } from './contexts/SocketContext'
import { ChatProvider } from './contexts/ChatContext'
import ChatSidebar from './components/ChatSidebar'
import ChatPanel from './components/ChatPanel'
import styles from './Chat.module.css'

export default function ChatPage() {
  const { permissions, loaded, isAdmin } = usePermissions()
  const identityPermissions = permissions?.chat?.chat
  
  usePermissionsRedirect({ identityPermissions, isAdmin, loaded })

  return (
    <SocketProvider>
      <ChatProvider>
        <Card className={styles.page}>
          <PageTitle title="Корпоративный чат" />
          <div className={styles.chat}>
            <ChatSidebar />
            <ChatPanel />
          </div>
        </Card>
      </ChatProvider>
    </SocketProvider>
  )
}

usePermissions() – получает текущие права пользователя и может быть использован для условного рендеринга UI;
usePermissionsRedirect() – автоматически перенаправляет пользователя, если у него нет нужных прав, что особенно полезно в админ-панелях;

Card - компонент Admiral для визуального оформления секции. Подробнее ознакомиться с компонентом можно на демо-стенде: https://admiral.dev.family/components/card;
PageTitle - компонент Admiral для единообразного отображения заголовков страниц. Демо: https://admiral.dev.family/components/typography;

Управление WebSocket-соединением с SocketContext

Для работы чата в реальном времени необходимы WebSockets. В данном примере будем использовать библиотеку Centrifuge. Все управление соединением вынесем в SocketContext.

// src/crud/chat/SocketContext.tsx

import React from 'react'

import { Centrifuge } from 'centrifuge'
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react'
import { useGetIdentity } from '@devfamily/admiral'

const SocketContext = createContext(null)

export const SocketProvider = ({ children }: { children: ReactNode }) => {
    const { identity: user } = useGetIdentity()
    const [lastMessage, setLastMessage] = useState(null)
    const centrifugeRef = useRef(null)
    const subscribedRef = useRef(false)

    useEffect(() => {
        if (!user?.ws_token) return

        const WS_URL = import.meta.env.VITE_WS_URL
        if (!WS_URL) {
            console.error('❌ Missing VITE_WS_URL in env')
            return
        }

        const centrifuge = new Centrifuge(WS_URL, {
            token: user.ws_token, // Инициализация WebSocket по токену
        })
        
        centrifugeRef.current = centrifuge
        centrifugeRef.current.connect()

        // Подписка на канал чата
        const sub = centrifugeRef.current.newSubscription(`admin_chat`)

        sub.on('publication', function (ctx: any) {
		       setLastMessage(ctx.data);
        }).subscribe()

        // Очистка при размонтировании
        return () => {
            subscribedRef.current = false
            centrifuge.disconnect()
        }
    }, [user?.ws_token])

    return (
        <SocketContext.Provider value={{ lastMessage, centrifuge: centrifugeRef.current }}>
            {children}
        </SocketContext.Provider>
    )
}

export const useSocket = () => {
    const ctx = useContext(SocketContext)
    if (!ctx) throw new Error('useSocket must be used within SocketProvider')
    return ctx
}

Centrifuge – библиотека для работы с WebSockets;
useGetIdentity() - хук Admiral, который получает информацию о текущем пользователе, включая ws_token, необходимый для аутентификации WebSocket-соединения;
useEffect – инициализирует и управляет жизненным циклом WebSocket-соединения. Важно отметить очистку соединения (centrifuge.disconnect()) при размонтировании компонента, чтобы избежать утечек памяти и нежелательных соединений;

Подписка на канал your_channel_name – все сообщения, относящиеся к административному чату, будут приходить по этому каналу;
Обработчик on('publication') – получает новые сообщения и события (например, message_read или new_message) и обновляет состояние lastMessage.

Управление состоянием чата с ChatContext

ChatContext отвечает за загрузку, хранение и обновление диалогов и сообщений.

// src/crud/chat/ChatContext.tsx

import React, { useRef } from "react";

import {
  createContext,
  useContext,
  useEffect,
  useState,
  useRef,
  useCallback,
} from "react";
import { useSocket } from "./SocketContext";
import { useUrlState } from "@devfamily/admiral";
import api from "../api";

const ChatContext = createContext(null);

export const ChatProvider = ({ children }) => {
  const { lastMessage } = useSocket();
  const [dialogs, setDialogs] = useState([]);
  const [messages, setMessages] = useState([]);
  const [selectedDialog, setSelectedDialog] = useState(null);
  const [urlState] = useUrlState();
  const { client_id } = urlState;

  const fetchDialogs = useCallback(async () => {
    const res = await api.dialogs();
    setDialogs(res.data || []);
  }, []);

  const fetchMessages = useCallback(async (id) => {
    const res = await api.messages(id);
    setMessages(res.data || []);
  }, []);

  useEffect(() => {
    fetchMessages(client_id);
  }, [fetchMessages, client_id]);

  useEffect(() => {
    fetchDialogs();
  }, [fetchDialogs]);

  useEffect(() => {
    if (!lastMessage) return;

    fetchDialogs();

    setMessages((prev) => [...prev, lastMessage.data]);
  }, [lastMessage]);

  const sendMessage = useCallback(
    async (value, onSuccess, onError) => {
      try {
        const res = await api.send(value);
        if (res?.data) setMessages((prev) => [...prev, res.data]);
        fetchDialogs();
        onSuccess();
      } catch (err) {
        onError(err);
      }
    },
    [messages]
  );
  
  // В этом контексте вы можете расширить логику:
  // - Отмечать сообщения как прочитанные (api.read())
  // - Группировать сообщения по дате и т.п.

  return (
    <ChatContext.Provider
      value={{
        dialogs,
        messages: groupMessagesByDate(messages),
        selectedDialog,
        setSelectedDialog,
        sendMessage,
      }}
    >
      {children}
    </ChatContext.Provider>
  );
};

export const useChat = () => {
  const ctx = useContext(ChatContext);
  if (!ctx) throw new Error("useChat must be used within ChatProvider");
  return ctx;
};

useUrlState – хук Admiral для синхронизации состояния с URL;
useSocket() – получает последние сообщения из SocketContext для real-time обновлений.
fetchMessages и fetchDialogs – асинхронные функции для получения сообщений и списка диалогов с сервера;
useEffect для lastMessage – обрабатывает новые сообщения, приходящие через WebSocket;
sendMessage – функция для отправки сообщений на сервер, которая также обновляет локальное состояние чата и список диалогов.

Пример API-клиента

// src/crud/chat/api.ts

import _ from '../../config/request'
import { apiUrl } from '@/src/config/api'

const api = {
    dialogs: () => _.get(`${apiUrl}/chat/dialogs`)(),
    messages: (id) => _.get(`${apiUrl}/chat/messages/${id}`)(),
    send: (data) => _.postFD(`${apiUrl}/chat/send`)({ data }),
    read: (data) => _.post(`${apiUrl}/chat/read`)({ data }),
}

export default api

_ - утилиты для работы с API (GET, POST, FormData).

Компоненты пользовательского интерфейса: Sidebar + Panel + Input

Рассмотрим ключевые UI-компоненты, из которых собирается интерфейс чата.

4.1. ChatSidebar – Список диалогов

// src/crud/chat/components/ChatSidebar.tsx

import React from "react";

import styles from "./ChatSidebar.module.scss";
import ChatSidebarItem from "../ChatSidebarItem/ChatSidebarItem";
import { useChat } from "../../model/ChatContext";

function ChatSidebar({}) {
  const { dialogs } = useChat();
  
    if (!dialogs.length) {
    return (
      <div className={styles.empty}>
        <span>Нет активных диалогов</span>
      </div>
    );
  }

  return <div className={styles.list}>
      {dialogs.map((item) => (
        <ChatSidebarItem key={item.id} data={item} />
      ))}
    </div>
}

export default ChatSidebar;

4.2. ChatSidebarItem – Элемент списка диалогов

// src/crud/chat/components/ChatSidebarItem.tsx

import React from "react";

import { Badge } from '@devfamily/admiral'
import dayjs from "dayjs";
import { BsCheck2, BsCheck2All } from "react-icons/bs";
import styles from "./ChatSidebarItem.module.scss";

function ChatSidebarItem({ data }) {
  const { client_name, client_id, last_message, last_message_ } = data;

  const [urlState, setUrlState] = useUrlState();
  const { client_id } = urlState;

  const { setSelectedDialog } = useChat();

  const onSelectDialog = useCallback(() => {
    setUrlState({ client_id: client.id });
    setSelectedDialog(data);
  }, [order.id]);

  return (
    <div
      className={`${styles.item} ${isSelected ? styles.active : ""}`}
      onClick={onSelectDialog}
      role="button"
    >
      <div className={styles.avatar}>{client_name.charAt(0).toUpperCase()}</div>

      <div className={styles.content}>
        <div className={styles.header}>
          <span className={styles.name}>{client_name}</span>
          <span className={styles.time}>
            {dayjs(last_message_).format("HH:mm")}
            {message.is_read ? (
              <BsCheck2All size="16px" />
            ) : (
              <BsCheck2 size="16px" />
            )}
          </span>
        </div>
        <span className={styles.preview}>{last_message.text}</span>
        {unread_count > 0 && (
            <Badge>{unread_count}</Badge>
          )}
      </div>
    </div>
  );
}

export default ChatSidebarItem;

4.3 ChatPanel – Панель сообщений

// src/crud/chat/components/ChatPanel.tsx

import React from "react";

import { Card } from '@devfamily/admiral';
import { useChat } from "../../contexts/ChatContext";
import MessageFeed from "../MessageFeed";
import MessageInput from "../MessageInput";
import styles from "./ChatPanel.module.scss";

function ChatPanel() {
  const { selectedDialog } = useChat();
  
  if (!selectedDialog) {
    return (
      <Card className={styles.emptyPanel}>
        <div className={styles.emptyState}>
          <h3>Выберите диалог</h3>
          <p>Выберите диалог из списка для начала общения</p>
        </div>
      </Card>
    );
  }
  
  return (
    <div className={styles.panel}>
      <MessageFeed />
      <div className={styles.divider} />
      <MessageInput />
    </div>
  );
}

export default ChatPanel;

4.4. MessageFeed – Лента сообщений

// src/crud/chat/components/MessageFeed.tsx

import React, { useRef, useEffect } from "react";

import { BsCheck2, BsCheck2All } from "react-icons/bs";
import { useChat } from "../../contexts/ChatContext";
import MessageItem from "../MessageItem";
import styles from "./MessageFeed.module.scss";

function MessageFeed() {
  const { messages } = useChat();
  const scrollRef = useRef(null);

  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: "auto" });
  }, [messages]);

  return (
    <div ref={scrollRef} className={styles.feed}>
      {messages.map((group) => (
        <div key={group.date} className={styles.dateGroup}>
          <div className={styles.dateDivider}>
            <span>{group.date}</span>
          </div>
          {group.messages.map((msg) => (
            <div className={styles.message}>
              {msg.text && <p>{msg.text}</p>}
              {msg.image && (
                <img
                  src={msg.image}
                  alt=""
                  style={{ maxWidth: "200px", borderRadius: 4 }}
                />
              )}
              {msg.file && (
                <a href={msg.file} target="_blank" rel="noopener noreferrer">
                  Скачать файл
                </a>
              )}
              <div style={{ fontSize: "0.8rem", opacity: 0.6 }}>
                {dayjs(msg.created_at).format("HH:mm")}
                {msg.is_read ? <BsCheck2All /> : <BsCheck2 />}
              </div>
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

export default MessageFeed;

4.5. MessageInput – Поле ввода сообщения

// src/crud/chat/components/MessageInput.tsx

import React from "react";

import {
  ChangeEventHandler,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";

import { FiPaperclip } from "react-icons/fi";
import { RxPaperPlane } from "react-icons/rx";
import { Form, Button, useUrlState, Textarea } from "@devfamily/admiral";

import { useChat } from "../../model/ChatContext";

import styles from "./MessageInput.module.scss";

function MessageInput() {
  const { sendMessage } = useChat();
  const [urlState] = useUrlState();
  const { client_id } = urlState;
  const [values, setValues] = useState({});
  const textRef = useRef < HTMLTextAreaElement > null;

  useEffect(() => {
    setValues({});
    setErrors(null);
  }, [client_id]);

  const onSubmit = useCallback(
    async (e?: React.FormEvent<HTMLFormElement>) => {
      e?.preventDefault();
      const textIsEmpty = !values.text?.trim()?.length;

      sendMessage(
        {
          ...(values.image && { image: values.image }),
          ...(!textIsEmpty && { text: values.text }),
          client_id,
        },
        () => {
          setValues({ text: "" });
        },
        (err: any) => {
          if (err.errors) {
            setErrors(err.errors);
          }
        }
      );
    },
    [values, sendMessage, client_id]
  );

  const onUploadFile: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      const file = Array.from(e.target.files || [])[0];
      setValues((prev: any) => ({ ...prev, image: file }));
      e.target.value = "";
    },
    [values]
  );

  const onChange = useCallback((e) => {
    setValues((prev) => ({ ...prev, text: e.target.value }));
  }, []);

  const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if ((e.code === "Enter" || e.code === " NumpadEnter") && !e.shiftKey) {
 onSubmit();
 e.preventDefault();
 }
 }, [onSubmit]);

return (
 
 <Textarea
 value={values.text ?? ""}
 onChange={onChange}
 rows={1}
 onKeyDown={onKeyDown}
 placeholder="Написать сообщение..."
 ref={textRef}
 className={styles.textarea}
 />
 <Button
 view="secondary"
 type="submit"
 disabled={!values.image && !values.text?.trim().length}
 className={styles.submitBtn}
 >
 
 
 
 );
 }

export default MessageInput;



---

## Стилизация  

```css
.chat {
  border-radius: var(--radius-m);
  border: 2px solid var(--color-bg-border);
  background-color: var(--color-bg-default);
}

.message {
  padding: var(--space-m);
  border-radius: var(--radius-s);
  background-color: var(--color-bg-default);
}

Все переменные, которые доступны в Admiral можно найти тут: https://github.com/dev-family/admiral/tree/master/src/theme.

Добавление уведомлений прямо в ChatContext

import { useNotifications } from '@devfamily/admiral'

const ChatContext = () => {
  const { showNotification } = useNotifications()
  
  useEffect(() => {
    if (!lastMessage) return
    
    if (selectedDialog?.client_id !== lastMessage.client_id) {
      showNotification({
        title: 'Новое сообщение',
        message: `${lastMessage.client_name}: ${lastMessage.text || 'Изображение'}`,
        type: 'info',
        duration: 5000
      })
    }
  }, [lastMessage, selectedDialog, showNotification])
}

Заключение

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

  1. Простота интеграции – никаких сложных настроек роутинга или конфигураций

  2. Гибкость архитектуры – легко добавлять собственные контексты и компоненты

  3. Встроенные возможности – хуки для авторизации, темизации, навигации работают из коробки.

  4. Консистентность дизайна – все компоненты автоматически соответствуют общему стилю.

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

Комментарии (0)