Refactor chat settings and tighten frontend types
This commit is contained in:
parent
613248905e
commit
a8ec339565
@ -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
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
110
frontend/src/components/messages/AddChatMemberModal.tsx
Normal file
110
frontend/src/components/messages/AddChatMemberModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
frontend/src/components/messages/ChatParticipantsSection.tsx
Normal file
219
frontend/src/components/messages/ChatParticipantsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/messages/EditChatModal.tsx
Normal file
121
frontend/src/components/messages/EditChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
81
frontend/src/lib/messages/chatSettingsUtils.ts
Normal file
81
frontend/src/lib/messages/chatSettingsUtils.ts
Normal 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() || "?"
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user