Mein erster Gedanke war, ob ich nur ein vorhandenes Tool wie Jivo oder LiveChat integrieren sollte, aber ich wollte nicht auf Produkte von Drittanbietern angewiesen sein für etwas, das direkt in mein Admin-Panel eingebaut werden könnte. In diesem Beitrag werde ich durchgehen, wie ich es gebaut habe: die Architektur, die Kontexte für Sockets und Status und die UI-Komponenten, die alles zusammenführen. Warum ? Admiral Admiral Es ist so konzipiert, dass es erweiterbar ist.Mit filenbasiertem Routing, Hooks und flexiblen Komponenten, es schließt Sie nicht ein – es gibt Ihnen Raum, um benutzerdefinierte Features zu implementieren.Das ist genau das, was ich für den Chat brauchte: nicht nur CRUD, sondern Echtzeit-Messaging, das immer noch nahtlos in das Panel passt. Admiral Chat Architektur So habe ich die Dinge strukturiert: Core components ChatPage – die Haupt-Chat-Seite ChatSidebar – Gesprächsliste mit Vorschau ChatPanel – macht den ausgewählten Chat MessageFeed – der Thread der Nachrichten MessageInput – die Eingabe mit Datei-Upload Context providers SocketContext – verwaltet WebSocket-Verbindungen ChatContext – verwaltet Dialoge und den Messaging-Status Hauptseite Chat Mit Admirals Routing war das Einrichten einer neuen Seite einfach. // pages/chat/index.tsx import ChatPage from '@/src/crud/chat' export default ChatPage Das reicht aus, um die Seite in . /chat Die Hauptimplementierung erfolgte in : 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="Corporate chat" /> <div className={styles.chat}> <ChatSidebar /> <ChatPanel /> </div> </Card> </ChatProvider> </SocketProvider> ) } Hier habe ich die Seite in und , und benutzte Admiral's Hooks für Berechtigungen und Umleitungen. SocketProvider ChatProvider Verwaltung von WebSocket-Verbindungen mit SocketContext Für den Echtzeit-Chat entschied ich mich für Centrifuge.Ich wollte alle Verbindungslogik an einem Ort haben, also habe ich : 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, // Initializing the WebSocket connection with a token }) centrifugeRef.current = centrifuge centrifugeRef.current.connect() // Subscribing to the chat channel const sub = centrifugeRef.current.newSubscription(`admin_chat`) sub.on('publication', function (ctx: any) { setLastMessage(ctx.data); }).subscribe() // Cleaning up on component unmount 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 } In diesem Kontext wurden Verbindungseinrichtung, Abonnement und Reinigung abgewickelt.Andere Teile der App wurden gerade verwendet . useSocket() Chat-Status mit ChatContext verwalten Als nächstes musste ich Dialoge abrufen, Nachrichten laden, neue senden und auf WebSocket-Updates reagieren. : 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] ); // Within this context, you can extend the logic to: // – Mark messages as read (api.read()) // – Group messages by date, and more. 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; }; Dies hielt alles - abholen, speichern, aktualisieren - an einem Ort. API Client Beispiel Ich habe einen kleinen API-Client für Anfragen hinzugefügt: // 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 Benutzeroberfläche: Sidebar + Panel + Input Dann zog ich zum UI Layer. ChatSitzung // 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>No active активных dialogs</span> </div> ); } return <div className={styles.list}> {dialogs.map((item) => ( <ChatSidebarItem key={item.id} data={item} /> ))} </div> } export default ChatSidebar; ChatZurück // 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; 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>Choose the dialog</h3> <p>Choose the dialog from the list to start conversation</p> </div> </Card> ); } return ( <div className={styles.panel}> <MessageFeed /> <div className={styles.divider} /> <MessageInput /> </div> ); } export default ChatPanel; NachrichtFeed // 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; Nachrichtensendung // 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 ( <form className={styles.form} onSubmit={onSubmit}> <label className={styles.upload}> <input type="file" onChange={onUploadFile} className={styles.visuallyHidden} /> <FiPaperclip size="24px" /> </label> <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} > <RxPaperPlane /> </Button> </form> ); } export default MessageInput; Styling Ich habe es mit den CSS-Variablen von Admiral gestaltet, um alles konsistent zu halten: .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); } Notifikationen hinzufügen Ich habe auch Benachrichtigungen für neue Nachrichten hinzugefügt, wenn der Benutzer diesen Chat nicht anzeigt: import { useNotifications } from '@devfamily/admiral' const ChatContext = () => { const { showNotification } = useNotifications() useEffect(() => { if (!lastMessage) return if (selectedDialog?.client_id !== lastMessage.client_id) { showNotification({ title: 'New message', message: `${lastMessage.client_name}: ${lastMessage.text || 'Image'}`, type: 'info', duration: 5000 }) } }, [lastMessage, selectedDialog, showNotification]) } Schlussfolgerung Und genau so, anstatt Drittanbieter-Tools zu verwenden, habe ich es direkt in mein Admiral-basiertes Admin-Panel aufgebaut.Admirals Routing, Kontexte, Haken und Design-System machten es möglich, einen Echtzeit-Chat zu erstellen, der sich dem Panel nativ anfühlte. Das Ergebnis war ein vollständig benutzerdefinierter Chat: Echtzeitnachrichten, Dialoge, Datei-Upload und Benachrichtigungen – alles integriert und unter meiner Kontrolle. Überprüfe es und lass mich wissen, was du denkst!