Refactor chat settings and tighten frontend types

This commit is contained in:
lizerk 2026-04-01 15:31:19 +03:00
parent 613248905e
commit a8ec339565
9 changed files with 646 additions and 510 deletions

View File

@ -252,6 +252,10 @@ export default function MessageDialogPage() {
handlers.current.onMessage = (data) => {
if (data.type === 'new_message') {
// Новое сообщение
if (!data.message) {
return;
}
const newMsg = data.message;
const isMyMessage = newMsg.author_id === user?.id;
const wasNearBottom = isNearBottom();
@ -313,6 +317,10 @@ export default function MessageDialogPage() {
}
else if (data.type === 'message_edited') {
// Редактирование сообщения
if (!data.message) {
return;
}
const editedMsg = data.message;
setMessages(prev => prev.map(m =>
m.id === editedMsg.id ? { ...m, ...editedMsg } : m
@ -347,22 +355,24 @@ export default function MessageDialogPage() {
}
else if (data.type === 'reaction_added') {
// Реакция добавлена
if (data.reactions_summary && data.message_id) {
if (data.reactions_summary && typeof data.message_id === 'number') {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
else if (data.type === 'reaction_removed') {
// Реакция удалена
if (data.reactions_summary && data.message_id) {
if (data.reactions_summary && typeof data.message_id === 'number') {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') {
const lastReadMessageId = data.last_read_message_id;
setChat((prev) =>
prev
? {
...prev,
last_read_message_id: Math.max(prev.last_read_message_id ?? 0, data.last_read_message_id),
last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId),
}
: prev
);

View File

@ -10,46 +10,20 @@ import {
BellOff,
Pin,
Trash2,
UserPlus,
UserMinus,
Users,
Pencil,
ChevronDown,
Shield,
ShieldCheck
Pencil
} from "lucide-react";
import { AppShell } from "../../../../components/AppShell";
import { apiClient } from "@/lib/api";
import { getChatAvatar, getChatInitials, getChatTitle, getUserFullName } from "@/lib/messages/chatUtils";
import type { Chat, User } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
import ChatParticipantsSection from "@/components/messages/ChatParticipantsSection";
import EditChatModal from "@/components/messages/EditChatModal";
import AddChatMemberModal from "@/components/messages/AddChatMemberModal";
import { getChatTypeLabel, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
import type { Chat } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { resolveMediaUrl } from "@/lib/url";
type MemberSearchResult = Pick<User, "id" | "first_name" | "last_name" | "email" | "avatar"> & {
name?: string;
};
function getChatTypeLabel(chat: Chat): string {
const type = chat.chat_type || chat.type;
switch (type) {
case "global":
return "Глобальный чат";
case "channel":
return "Канал";
case "private":
case "direct":
return "Личный чат";
case "group":
return "Групповой чат";
case "announcement":
return "Канал объявлений";
case "comments":
return "Комментарии";
default:
return "Диалог";
}
}
export default function ChatSettingsPage() {
const params = useParams<{ chatId: string }>();
const router = useRouter();
@ -264,7 +238,7 @@ export default function ChatSettingsPage() {
}
// Перезагружаем данные чата
const refreshedChat = await apiClient.getChat(chatId);
const refreshedChat: Chat = await apiClient.getChat(chatId);
setChat(refreshedChat);
handleCloseEditModal();
@ -323,7 +297,7 @@ export default function ChatSettingsPage() {
await apiClient.addChatMember(chatId, userId);
// Перезагружаем данные чата
const refreshedChat = await apiClient.getChat(chatId);
const refreshedChat: Chat = await apiClient.getChat(chatId);
setChat(refreshedChat);
// Обновляем результаты поиска с учетом новых данных
@ -390,7 +364,7 @@ export default function ChatSettingsPage() {
});
// Перезагружаем данные чата с сервера для синхронизации
const refreshedChat = await apiClient.getChat(chatId);
const refreshedChat: Chat = await apiClient.getChat(chatId);
console.log('[handleChangeRole] Refreshed chat from server:', refreshedChat);
console.log('[handleChangeRole] Refreshed memberships:', refreshedChat.memberships);
setChat(refreshedChat);
@ -458,37 +432,6 @@ export default function ChatSettingsPage() {
) ?? false;
const canEdit = isOwner || isAdmin;
// Получить роль участника
const getMemberRole = (userId: number): 'admin' | 'moderator' | 'member' | 'guest' | null => {
if (chat.created_by === userId) return null; // Владелец не имеет роли в membership
const membership = chat.memberships?.find(m => m.user === userId);
return membership?.role || 'member';
};
// Получить отображаемое имя роли
const getRoleLabel = (role: 'admin' | 'moderator' | 'member' | 'guest' | null): string => {
if (role === null) return 'Владелец';
switch (role) {
case 'admin': return 'Админ';
case 'moderator': return 'Модератор';
case 'member': return 'Участник';
case 'guest': return 'Гость';
default: return 'Участник';
}
};
// Цвет бейджа роли
const getRoleBadgeColor = (role: 'admin' | 'moderator' | 'member' | 'guest' | null): string => {
if (role === null) return 'bg-purple-100 text-purple-700 ring-purple-200'; // Владелец
switch (role) {
case 'admin': return 'bg-red-100 text-red-700 ring-red-200';
case 'moderator': return 'bg-blue-100 text-blue-700 ring-blue-200';
case 'member': return 'bg-gray-100 text-gray-700 ring-gray-200';
case 'guest': return 'bg-amber-100 text-amber-700 ring-amber-200';
default: return 'bg-gray-100 text-gray-700 ring-gray-200';
}
};
return (
<AppShell>
<div className="mx-auto max-w-3xl">
@ -606,219 +549,21 @@ export default function ChatSettingsPage() {
{/* Участники (для групповых чатов, каналов и объявлений) */}
{hasMembers && (
<section className="mb-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-700">Участники</h3>
{isOwner && (
<p className="mt-0.5 text-xs text-gray-500">
Вы можете изменять роли участников
</p>
)}
</div>
{canEdit && (
<button
type="button"
onClick={handleOpenAddMemberModal}
className="flex items-center gap-1 rounded-lg border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs font-medium text-sky-700 transition hover:bg-sky-100"
>
<UserPlus size={14} />
<span>Добавить</span>
</button>
)}
</div>
<div className="space-y-2">
{(chat.participant_details || [])
.slice(0, showAllParticipants ? undefined : 5)
.map((participant) => {
const memberRole = getMemberRole(participant.id);
const roleLabel = getRoleLabel(memberRole);
const roleBadgeColor = getRoleBadgeColor(memberRole);
const isCurrentUserOwner = chat.created_by === participant.id;
return (
<div
key={participant.id}
className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3"
>
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
{participant.avatar ? (
<Image
src={resolveMediaUrl(participant.avatar)}
alt={participant.name || "User"}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>
{(participant.name || "?")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0])
.join("")
.toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{participant.name || "Без имени"}
{participant.id === currentUserId && (
<span className="ml-1.5 text-xs text-gray-500">(вы)</span>
)}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ${roleBadgeColor}`}>
{isCurrentUserOwner && <ShieldCheck size={10} />}
{!isCurrentUserOwner && memberRole === 'admin' && <Shield size={10} />}
{roleLabel}
</span>
</div>
</div>
{/* Управление ролью (только для владельца) */}
{isOwner && !isCurrentUserOwner && participant.id !== currentUserId && (
<div className="relative">
<button
type="button"
onClick={() => setRoleDropdownOpen(roleDropdownOpen === participant.id ? null : participant.id)}
disabled={actionLoading?.startsWith(`change-role-${participant.id}`) || actionLoading === `remove-member-${participant.id}`}
className="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
title="Изменить роль"
>
<span>Роль</span>
<ChevronDown size={12} />
</button>
{roleDropdownOpen === participant.id && (
<>
<button
type="button"
onClick={() => setRoleDropdownOpen(null)}
className="fixed inset-0 z-40"
aria-label="Закрыть меню"
/>
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button
type="button"
onClick={() => handleChangeRole(participant.id, 'admin')}
disabled={memberRole === 'admin'}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Shield size={12} className="text-red-600" />
<span>Админ</span>
</button>
<button
type="button"
onClick={() => handleChangeRole(participant.id, 'moderator')}
disabled={memberRole === 'moderator'}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Shield size={12} className="text-blue-600" />
<span>Модератор</span>
</button>
<button
type="button"
onClick={() => handleChangeRole(participant.id, 'member')}
disabled={memberRole === 'member'}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Users size={12} className="text-gray-600" />
<span>Участник</span>
</button>
<button
type="button"
onClick={() => handleChangeRole(participant.id, 'guest')}
disabled={memberRole === 'guest'}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Users size={12} className="text-amber-600" />
<span>Гость</span>
</button>
</div>
</>
)}
</div>
)}
{/* Кнопка удаления */}
{canEdit && !isCurrentUserOwner && participant.id !== currentUserId && (
<button
type="button"
onClick={() => handleRemoveMember(participant.id)}
disabled={actionLoading === `remove-member-${participant.id}` || actionLoading?.startsWith(`change-role-${participant.id}`)}
className="text-gray-400 hover:text-red-600 disabled:opacity-50"
title="Удалить из чата"
>
<UserMinus size={16} />
</button>
)}
</div>
);
})}
</div>
{/* Кнопка показать всех участников */}
{(chat.participant_details || []).length > 5 && (
<button
type="button"
onClick={() => setShowAllParticipants(!showAllParticipants)}
className="mt-3 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
>
{showAllParticipants
? "Свернуть"
: `Посмотреть всех (${chat.participant_details?.length || 0})`
}
</button>
)}
{/* Легенда ролей */}
{isOwner && (
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<p className="mb-2 text-xs font-semibold text-gray-700">Роли участников:</p>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-purple-100 text-purple-700 ring-1 ring-purple-200">
<ShieldCheck size={10} />
Владелец
</span>
<span>— полный контроль над чатом</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 ring-1 ring-red-200">
<Shield size={10} />
Админ
</span>
<span>— управление участниками, редактирование</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-blue-100 text-blue-700 ring-1 ring-blue-200">
<Shield size={10} />
Модератор
</span>
<span>— закрепление и удаление сообщений</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-700 ring-1 ring-gray-200">
Участник
</span>
<span>— отправка сообщений</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-700 ring-1 ring-amber-200">
Гость
</span>
<span>— только чтение</span>
</div>
</div>
</div>
)}
</section>
<ChatParticipantsSection
chat={chat}
currentUserId={currentUserId}
isOwner={isOwner}
canEdit={canEdit}
showAllParticipants={showAllParticipants}
roleDropdownOpen={roleDropdownOpen}
actionLoading={actionLoading}
onOpenAddMemberModal={handleOpenAddMemberModal}
onToggleShowAllParticipants={() => setShowAllParticipants((prev) => !prev)}
onToggleRoleDropdown={(participantId) => setRoleDropdownOpen((prev) => prev === participantId ? null : participantId)}
onCloseRoleDropdown={() => setRoleDropdownOpen(null)}
onChangeRole={handleChangeRole}
onRemoveMember={handleRemoveMember}
/>
)}
{/* Опасная зона */}
@ -869,207 +614,31 @@ export default function ChatSettingsPage() {
)}
</div>
{/* Модал редактирования чата */}
{isEditModalOpen && chat && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
<div className="space-y-4">
{/* Аватар */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Аватар
</label>
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
{editAvatarPreview ? (
<img
src={editAvatarPreview || ""}
alt="Preview"
className="h-full w-full object-cover"
/>
) : getChatAvatar(chat, currentUserId) ? (
<Image
src={resolveMediaUrl(getChatAvatar(chat, currentUserId))}
alt="Avatar"
width={64}
height={64}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat, currentUserId)
)}
</div>
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
Загрузить
</label>
</div>
<p className="mt-1 text-xs text-gray-500">
Максимальный размер: 5MB. Форматы: JPG, PNG, GIF
</p>
</div>
<EditChatModal
chat={chat}
open={isEditModalOpen}
currentUserId={currentUserId}
editName={editName}
editDescription={editDescription}
editAvatarPreview={editAvatarPreview}
actionLoading={actionLoading}
onClose={handleCloseEditModal}
onNameChange={setEditName}
onDescriptionChange={setEditDescription}
onAvatarChange={handleAvatarChange}
onSave={handleSaveEdit}
/>
{/* Название */}
<div>
<label htmlFor="edit-name" className="mb-2 block text-sm font-medium text-gray-700">
Название чата
</label>
<input
id="edit-name"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Введите название чата"
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
maxLength={100}
/>
</div>
{/* Описание */}
<div>
<label htmlFor="edit-description" className="mb-2 block text-sm font-medium text-gray-700">
Описание
</label>
<textarea
id="edit-description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Введите описание чата (необязательно)"
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
maxLength={500}
/>
</div>
</div>
{/* Кнопки */}
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={handleCloseEditModal}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={handleSaveEdit}
disabled={actionLoading === "edit" || !editName.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
</div>
)}
{/* Модал добавления участника */}
{isAddMemberModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
<button
type="button"
onClick={handleCloseAddMemberModal}
className="text-gray-400 hover:text-gray-600"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Поиск */}
<div className="mb-4">
<input
type="text"
value={memberSearchQuery}
onChange={(e) => handleMemberSearch(e.target.value)}
placeholder="Введите имя или email..."
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
autoFocus
/>
<p className="mt-1 text-xs text-gray-500">
Минимум 2 символа для поиска
</p>
</div>
{/* Результаты поиска */}
<div className="max-h-96 overflow-y-auto">
{memberSearchLoading ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Поиск...</p>
</div>
) : memberSearchQuery.trim().length < 2 ? (
<div className="py-8 text-center">
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">
Начните вводить имя или email для поиска
</p>
</div>
) : memberSearchResults.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Пользователи не найдены</p>
</div>
) : (
<div className="space-y-2">
{memberSearchResults.map((result) => (
<div
key={result.id}
className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50"
>
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{result.avatar ? (
<Image
src={resolveMediaUrl(result.avatar)}
alt={result.name || ""}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>
{result.name ? result.name.split(" ").map((n: string) => n[0]).join("") : "?"}
</span>
)}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">
{result.name || getUserFullName(result.last_name, result.first_name)}
</p>
{result.email && (
<p className="text-xs text-gray-500">{result.email}</p>
)}
</div>
<button
type="button"
onClick={() => handleAddMember(result.id)}
disabled={actionLoading === `add-member-${result.id}`}
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
<AddChatMemberModal
open={isAddMemberModalOpen}
memberSearchQuery={memberSearchQuery}
memberSearchResults={memberSearchResults}
memberSearchLoading={memberSearchLoading}
actionLoading={actionLoading}
onClose={handleCloseAddMemberModal}
onSearchChange={handleMemberSearch}
onAddMember={handleAddMember}
/>
</AppShell>
);
}

View File

@ -1,12 +1,13 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Bell, Settings, X, Trash2 } from 'lucide-react';
import { useNotifications } from '@/hooks/useApi';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale/ru';
import { getVerbName } from '@/lib/verbTranslations';
import Link from 'next/link';
import type { NotificationItem } from '@/contexts/NotificationsContext';
interface NotificationCenterProps {
variant?: 'default' | 'mobile';
@ -14,6 +15,18 @@ interface NotificationCenterProps {
onToggle?: () => void;
}
function getNotificationTitle(notification: NotificationItem): string {
return notification.title || getVerbName(notification.verb || '');
}
function getNotificationMessage(notification: NotificationItem): string {
return notification.description || notification.short_message || notification.message || '';
}
function getNotificationTimestamp(notification: NotificationItem): string | null {
return notification.timestamp || notification.created_at || null;
}
export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen, onToggle }: NotificationCenterProps) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isControlled = externalIsOpen !== undefined;
@ -32,13 +45,13 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
}
};
const close = () => {
const close = useCallback(() => {
if (onToggle && isOpen) {
onToggle();
} else {
setInternalIsOpen(false);
}
};
}, [isOpen, onToggle]);
// Закрытие при клике вне компонента (только для desktop варианта)
useEffect(() => {
@ -54,16 +67,16 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, variant]);
}, [close, isOpen, variant]);
const handleNotificationClick = async (notification: any) => {
const handleNotificationClick = async (notification: NotificationItem) => {
if (!notification.is_read) {
await markAsRead(notification.id);
}
// Навигация если есть action_url
if (notification.action_url) {
window.location.href = notification.action_url;
window.location.assign(notification.action_url);
}
};
@ -144,11 +157,9 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
<ul>
{notifications.slice(0, 20).map((notification) => {
const isUnread = !notification.is_read;
// v2 API: timestamp вместо created_at
const timestamp = notification.timestamp || notification.created_at;
// v2 API: verb + description вместо title + message
const title = notification.title || getVerbName(notification.verb);
const message = notification.description || notification.short_message || notification.message;
const timestamp = getNotificationTimestamp(notification);
const title = getNotificationTitle(notification);
const message = getNotificationMessage(notification);
return (
<li
@ -172,10 +183,10 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
{message}
</p>
<p className="text-[10px] text-gray-400">
{formatDistanceToNow(new Date(timestamp), {
{timestamp ? formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ru,
})}
}) : 'Только что'}
</p>
</div>
<button
@ -214,15 +225,15 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
/** Встраиваемая панель уведомлений (для выдвижных блоков) */
export function NotificationPanel({ onClose }: { onClose?: () => void }) {
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, deleteNotification, loading } = useNotifications();
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
const handleNotificationClick = async (notification: any) => {
const handleNotificationClick = async (notification: NotificationItem) => {
if (!notification.is_read) {
await markAsRead(notification.id);
}
if (notification.action_url) {
window.location.href = notification.action_url;
window.location.assign(notification.action_url);
}
};
@ -273,11 +284,9 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
<ul>
{notifications.slice(0, 20).map((notification) => {
const isUnread = !notification.is_read;
// v2 API: timestamp вместо created_at
const timestamp = notification.timestamp || notification.created_at;
// v2 API: verb + description вместо title + message
const title = notification.title || getVerbName(notification.verb);
const message = notification.description || notification.short_message || notification.message;
const timestamp = getNotificationTimestamp(notification);
const title = getNotificationTitle(notification);
const message = getNotificationMessage(notification);
return (
<li
@ -295,7 +304,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
<h4 className="font-medium text-sm mb-0.5 truncate text-gray-800">{title}</h4>
<p className="text-xs text-gray-600 line-clamp-2 mb-1">{message}</p>
<p className="text-[10px] text-gray-400">
{formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ru })}
{timestamp ? formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ru }) : 'Только что'}
</p>
</div>
</div>

View File

@ -0,0 +1,110 @@
"use client";
import Image from "next/image";
import { Users } from "lucide-react";
import { getParticipantInitials, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
import { getUserFullName } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
type AddChatMemberModalProps = {
open: boolean;
memberSearchQuery: string;
memberSearchResults: MemberSearchResult[];
memberSearchLoading: boolean;
actionLoading: string | null;
onClose: () => void;
onSearchChange: (value: string) => void;
onAddMember: (userId: number) => void;
};
export default function AddChatMemberModal({
open,
memberSearchQuery,
memberSearchResults,
memberSearchLoading,
actionLoading,
onClose,
onSearchChange,
onAddMember,
}: AddChatMemberModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mb-4">
<input
type="text"
value={memberSearchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Введите имя или email..."
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
autoFocus
/>
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
</div>
<div className="max-h-96 overflow-y-auto">
{memberSearchLoading ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Поиск...</p>
</div>
) : memberSearchQuery.trim().length < 2 ? (
<div className="py-8 text-center">
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
</div>
) : memberSearchResults.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Пользователи не найдены</p>
</div>
) : (
<div className="space-y-2">
{memberSearchResults.map((result) => (
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{result.avatar ? (
<Image
src={resolveMediaUrl(result.avatar)}
alt={result.name || getUserFullName(result.last_name, result.first_name)}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
)}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
</div>
<button
type="button"
onClick={() => onAddMember(result.id)}
disabled={actionLoading === `add-member-${result.id}`}
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import Image from "next/image";
import { ChevronDown, Shield, ShieldCheck, UserMinus, UserPlus, Users } from "lucide-react";
import { resolveMediaUrl } from "@/lib/url";
import {
getMemberRole,
getParticipantInitials,
getRoleBadgeColor,
getRoleLabel,
type ChatMemberRole,
} from "@/lib/messages/chatSettingsUtils";
import type { Chat } from "@/types/api";
type ChatParticipantsSectionProps = {
chat: Chat;
currentUserId?: number;
isOwner: boolean;
canEdit: boolean;
showAllParticipants: boolean;
roleDropdownOpen: number | null;
actionLoading: string | null;
onOpenAddMemberModal: () => void;
onToggleShowAllParticipants: () => void;
onToggleRoleDropdown: (participantId: number) => void;
onCloseRoleDropdown: () => void;
onChangeRole: (userId: number, newRole: Exclude<ChatMemberRole, null>) => void;
onRemoveMember: (userId: number) => void;
};
const assignableRoles: Array<Exclude<ChatMemberRole, null>> = ["admin", "moderator", "member", "guest"];
function getRoleIcon(role: ChatMemberRole) {
switch (role) {
case "admin":
return <Shield size={12} className="text-red-600" />;
case "moderator":
return <Shield size={12} className="text-blue-600" />;
case "guest":
return <Users size={12} className="text-amber-600" />;
default:
return <Users size={12} className="text-gray-600" />;
}
}
export default function ChatParticipantsSection({
chat,
currentUserId,
isOwner,
canEdit,
showAllParticipants,
roleDropdownOpen,
actionLoading,
onOpenAddMemberModal,
onToggleShowAllParticipants,
onToggleRoleDropdown,
onCloseRoleDropdown,
onChangeRole,
onRemoveMember,
}: ChatParticipantsSectionProps) {
const visibleParticipants = (chat.participant_details || []).slice(0, showAllParticipants ? undefined : 5);
return (
<section className="mb-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-700">Участники</h3>
{isOwner ? <p className="mt-0.5 text-xs text-gray-500">Вы можете изменять роли участников</p> : null}
</div>
{canEdit ? (
<button
type="button"
onClick={onOpenAddMemberModal}
className="flex items-center gap-1 rounded-lg border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs font-medium text-sky-700 transition hover:bg-sky-100"
>
<UserPlus size={14} />
<span>Добавить</span>
</button>
) : null}
</div>
<div className="space-y-2">
{visibleParticipants.map((participant) => {
const memberRole = getMemberRole(chat, participant.id);
const roleLabel = getRoleLabel(memberRole);
const roleBadgeColor = getRoleBadgeColor(memberRole);
const isParticipantOwner = chat.created_by === participant.id;
return (
<div key={participant.id} className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
{participant.avatar ? (
<Image
src={resolveMediaUrl(participant.avatar)}
alt={participant.name || "User"}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>{getParticipantInitials(participant.name)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">
{participant.name || "Без имени"}
{participant.id === currentUserId ? <span className="ml-1.5 text-xs text-gray-500">(вы)</span> : null}
</p>
<div className="mt-0.5 flex items-center gap-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ${roleBadgeColor}`}>
{isParticipantOwner ? <ShieldCheck size={10} /> : memberRole === "admin" ? <Shield size={10} /> : null}
{roleLabel}
</span>
</div>
</div>
{isOwner && !isParticipantOwner && participant.id !== currentUserId ? (
<div className="relative">
<button
type="button"
onClick={() => onToggleRoleDropdown(participant.id)}
disabled={actionLoading?.startsWith(`change-role-${participant.id}`) || actionLoading === `remove-member-${participant.id}`}
className="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
title="Изменить роль"
>
<span>Роль</span>
<ChevronDown size={12} />
</button>
{roleDropdownOpen === participant.id ? (
<>
<button type="button" onClick={onCloseRoleDropdown} className="fixed inset-0 z-40" aria-label="Закрыть меню" />
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{assignableRoles.map((role) => (
<button
key={`${participant.id}-${role}`}
type="button"
onClick={() => onChangeRole(participant.id, role)}
disabled={memberRole === role}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{getRoleIcon(role)}
<span>{getRoleLabel(role)}</span>
</button>
))}
</div>
</>
) : null}
</div>
) : null}
{canEdit && !isParticipantOwner && participant.id !== currentUserId ? (
<button
type="button"
onClick={() => onRemoveMember(participant.id)}
disabled={actionLoading === `remove-member-${participant.id}` || actionLoading?.startsWith(`change-role-${participant.id}`)}
className="text-gray-400 hover:text-red-600 disabled:opacity-50"
title="Удалить из чата"
>
<UserMinus size={16} />
</button>
) : null}
</div>
);
})}
</div>
{(chat.participant_details || []).length > 5 ? (
<button
type="button"
onClick={onToggleShowAllParticipants}
className="mt-3 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
>
{showAllParticipants ? "Свернуть" : `Посмотреть всех (${chat.participant_details?.length || 0})`}
</button>
) : null}
{isOwner ? (
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<p className="mb-2 text-xs font-semibold text-gray-700">Роли участников:</p>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-2 py-0.5 text-[10px] font-medium text-purple-700 ring-1 ring-purple-200">
<ShieldCheck size={10} />
Владелец
</span>
<span>— полный контроль над чатом</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-medium text-red-700 ring-1 ring-red-200">
<Shield size={10} />
Админ
</span>
<span>— управление участниками, редактирование</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200">
<Shield size={10} />
Модератор
</span>
<span>— закрепление и удаление сообщений</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-700 ring-1 ring-gray-200">Участник</span>
<span>— отправка сообщений</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-amber-200">Гость</span>
<span>— только чтение</span>
</div>
</div>
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,121 @@
"use client";
import type React from "react";
import Image from "next/image";
import { getChatAvatar, getChatInitials } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
type EditChatModalProps = {
chat: Chat | null;
open: boolean;
currentUserId?: number;
editName: string;
editDescription: string;
editAvatarPreview: string | null;
actionLoading: string | null;
onClose: () => void;
onNameChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onAvatarChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSave: () => void;
};
export default function EditChatModal({
chat,
open,
currentUserId,
editName,
editDescription,
editAvatarPreview,
actionLoading,
onClose,
onNameChange,
onDescriptionChange,
onAvatarChange,
onSave,
}: EditChatModalProps) {
if (!open || !chat) return null;
const avatar = getChatAvatar(chat, currentUserId);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Аватар</label>
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
{editAvatarPreview ? (
<Image src={editAvatarPreview} alt="Preview" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : avatar ? (
<Image src={resolveMediaUrl(avatar)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : (
getChatInitials(chat, currentUserId)
)}
</div>
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
<input type="file" accept="image/*" onChange={onAvatarChange} className="hidden" />
Загрузить
</label>
</div>
<p className="mt-1 text-xs text-gray-500">Максимальный размер: 5MB. Форматы: JPG, PNG, GIF</p>
</div>
<div>
<label htmlFor="edit-name" className="mb-2 block text-sm font-medium text-gray-700">
Название чата
</label>
<input
id="edit-name"
type="text"
value={editName}
onChange={(event) => onNameChange(event.target.value)}
placeholder="Введите название чата"
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
maxLength={100}
/>
</div>
<div>
<label htmlFor="edit-description" className="mb-2 block text-sm font-medium text-gray-700">
Описание
</label>
<textarea
id="edit-description"
value={editDescription}
onChange={(event) => onDescriptionChange(event.target.value)}
placeholder="Введите описание чата (необязательно)"
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
maxLength={500}
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "edit" || !editName.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
</div>
);
}

View File

@ -4,11 +4,19 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use
import apiClient from '@/lib/api';
import wsManager from '@/lib/websocketManager';
type NotificationItem = {
export type NotificationItem = {
id: number;
is_read?: boolean;
unread?: boolean;
category?: string;
title?: string;
verb?: string;
description?: string;
short_message?: string;
message?: string;
timestamp?: string;
created_at?: string;
action_url?: string;
[key: string]: unknown;
};
@ -101,15 +109,17 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
return;
}
if (data.type === 'notification' && data.notification) {
const incomingNotification = data.notification;
if (data.type === 'notification' && incomingNotification) {
setNotifications(prev => {
if (prev.some(n => n.id === data.notification.id)) {
if (prev.some(n => n.id === incomingNotification.id)) {
return prev;
}
return [data.notification, ...prev];
return [incomingNotification, ...prev];
});
const isRead = data.notification.is_read ?? !data.notification.unread;
const isRead = incomingNotification.is_read ?? !incomingNotification.unread;
if (!isRead) {
setUnreadCount(prev => prev + 1);
}

View File

@ -1,11 +1,18 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { getWebSocketUrl } from '@/lib/url';
import type { Message } from '@/types/api';
type ReactionsSummary = Record<string, { count: number; users?: number[]; user_names?: string[] }>;
interface WebSocketMessage {
type: string;
payload?: Record<string, unknown>;
message?: Message;
message_id?: number;
user_id?: number;
chat_id?: number;
last_read_message_id?: number;
reactions_summary?: ReactionsSummary;
[key: string]: unknown;
}

View File

@ -0,0 +1,81 @@
import type { Chat, ChatMembership, User } from "@/types/api";
export type MemberSearchResult = Pick<User, "id" | "first_name" | "last_name" | "email" | "avatar"> & {
name?: string;
};
export type ChatMemberRole = ChatMembership["role"] | null;
export function getChatTypeLabel(chat: Chat): string {
const type = chat.chat_type || chat.type;
switch (type) {
case "global":
return "Глобальный чат";
case "channel":
return "Канал";
case "private":
case "direct":
return "Личный чат";
case "group":
return "Групповой чат";
case "announcement":
return "Канал объявлений";
case "comments":
return "Комментарии";
default:
return "Диалог";
}
}
export function getMemberRole(chat: Chat, userId: number): ChatMemberRole {
if (chat.created_by === userId) return null;
const membership = chat.memberships?.find((item) => item.user === userId);
return membership?.role || "member";
}
export function getRoleLabel(role: ChatMemberRole): string {
if (role === null) return "Владелец";
switch (role) {
case "admin":
return "Админ";
case "moderator":
return "Модератор";
case "member":
return "Участник";
case "guest":
return "Гость";
default:
return "Участник";
}
}
export function getRoleBadgeColor(role: ChatMemberRole): string {
if (role === null) return "bg-purple-100 text-purple-700 ring-purple-200";
switch (role) {
case "admin":
return "bg-red-100 text-red-700 ring-red-200";
case "moderator":
return "bg-blue-100 text-blue-700 ring-blue-200";
case "member":
return "bg-gray-100 text-gray-700 ring-gray-200";
case "guest":
return "bg-amber-100 text-amber-700 ring-amber-200";
default:
return "bg-gray-100 text-gray-700 ring-gray-200";
}
}
export function getParticipantInitials(name?: string): string {
return (
(name || "?")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0] || "")
.join("")
.toUpperCase() || "?"
);
}