912 lines
40 KiB
TypeScript
912 lines
40 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
X,
|
||
Camera,
|
||
Edit3,
|
||
Check,
|
||
Loader2,
|
||
UserPlus,
|
||
Trash2,
|
||
Search,
|
||
Crown,
|
||
Users,
|
||
ImageIcon,
|
||
FileText,
|
||
Link as LinkIcon,
|
||
Play,
|
||
Download,
|
||
ExternalLink,
|
||
Video
|
||
} from 'lucide-react';
|
||
import Cropper from 'react-easy-crop';
|
||
import { ChatApi } from '../../infrastructure/chatApi';
|
||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||
import { useChatStore } from '../../application/chatStore';
|
||
import { useLang } from '../../../../lib/i18n';
|
||
import { Chat, UserPresence, Message } from '../../../../lib/types';
|
||
import Avatar from '../../../../components/Avatar';
|
||
import ConfirmModal from '../../../../components/ConfirmModal';
|
||
import ImageLightbox from '../../../../components/ImageLightbox';
|
||
import { getMediaUrl } from '../../../../lib/utils';
|
||
import { getCroppedImg } from '../../../../lib/imageCrop';
|
||
|
||
interface GroupSettingsProps {
|
||
chat: Chat;
|
||
onClose: () => void;
|
||
onGoToMessage?: (messageId: string) => void;
|
||
}
|
||
|
||
export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) {
|
||
const { user } = useAuthStore();
|
||
const { updateChat } = useChatStore();
|
||
const { t, lang } = useLang();
|
||
|
||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||
const isAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(currentMember?.role || '');
|
||
|
||
const [isEditingName, setIsEditingName] = useState(false);
|
||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||
const [groupName, setGroupName] = useState(chat.name || '');
|
||
const [groupDesc, setGroupDesc] = useState(chat.description || '');
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||
const [showAddMember, setShowAddMember] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<'members' | 'gifs' | 'media' | 'files' | 'links'>('members');
|
||
const [tabLoading, setTabLoading] = useState(false);
|
||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
|
||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
|
||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||
|
||
// Cropping states
|
||
const [isCropping, setIsCropping] = useState(false);
|
||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||
const [zoom, setZoom] = useState(1);
|
||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Keep local state in sync with chat prop
|
||
useEffect(() => {
|
||
setGroupName(chat.name || '');
|
||
setGroupDesc(chat.description || '');
|
||
}, [chat.name, chat.description]);
|
||
|
||
// Search users to add
|
||
useEffect(() => {
|
||
if (!searchQuery.trim()) {
|
||
setSearchResults([]);
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
setIsSearching(true);
|
||
const results = await UserApi.searchUsers(searchQuery);
|
||
// Filter out users already in the group
|
||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [searchQuery, chat.members]);
|
||
|
||
const handleSaveName = async () => {
|
||
if (!groupName.trim()) return;
|
||
try {
|
||
setIsSaving(true);
|
||
const updatedChat = await ChatApi.updateGroup(chat.id, { name: groupName.trim() });
|
||
updateChat(updatedChat);
|
||
setIsEditingName(false);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveDesc = async () => {
|
||
try {
|
||
setIsSaving(true);
|
||
const updatedChat = await ChatApi.updateGroup(chat.id, { description: groupDesc.trim() });
|
||
updateChat(updatedChat);
|
||
setIsEditingDesc(false);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
setCropImage(reader.result as string);
|
||
setCropFile(file);
|
||
setIsCropping(true);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
const handleCropSave = async () => {
|
||
if (!cropImage || !croppedAreaPixels) return;
|
||
setAvatarUploading(true);
|
||
try {
|
||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||
if (!croppedFile) throw new Error("Could not crop image");
|
||
|
||
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, croppedFile);
|
||
|
||
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
||
setIsCropping(false);
|
||
setCropImage(null);
|
||
setCropFile(null);
|
||
} catch (err) {
|
||
console.error('Failed to crop group avatar:', err);
|
||
alert(t('error'));
|
||
} finally {
|
||
setAvatarUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
setAvatarUploading(true);
|
||
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, file);
|
||
updateChat(updatedChat);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setAvatarUploading(false);
|
||
e.target.value = '';
|
||
}
|
||
};
|
||
|
||
const handleRemoveAvatar = async () => {
|
||
try {
|
||
setAvatarUploading(true);
|
||
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||
updateChat(updatedChat);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setAvatarUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleAddMember = async (userId: string) => {
|
||
try {
|
||
const updatedChat = await ChatApi.addGroupMembers(chat.id, [userId]);
|
||
updateChat(updatedChat);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const handleRemoveMember = async (userId: string) => {
|
||
setRemoveTargetId(userId);
|
||
};
|
||
|
||
const confirmRemoveMember = async () => {
|
||
if (!removeTargetId) return;
|
||
try {
|
||
const updatedChat = await ChatApi.removeGroupMember(chat.id, removeTargetId);
|
||
updateChat(updatedChat);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
setRemoveTargetId(null);
|
||
};
|
||
|
||
const initials = (chat.name || 'G')
|
||
.split(' ')
|
||
.map((w: string) => w[0])
|
||
.join('')
|
||
.slice(0, 2)
|
||
.toUpperCase();
|
||
|
||
const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => {
|
||
if (loadedTabs.has(tab)) return;
|
||
setTabLoading(true);
|
||
try {
|
||
const data = await ChatApi.getSharedMedia(chat.id, tab);
|
||
if (tab === 'media') setSharedMedia(data);
|
||
else if (tab === 'gifs') setSharedGifs(data);
|
||
else if (tab === 'files') setSharedFiles(data);
|
||
else setSharedLinks(data);
|
||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||
} catch (e) {
|
||
console.error('Failed to load shared', tab, e);
|
||
} finally {
|
||
setTabLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadTabData('gifs');
|
||
loadTabData('media');
|
||
loadTabData('files');
|
||
loadTabData('links');
|
||
}, [chat.id]);
|
||
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||
...m,
|
||
url: getMediaUrl(m.url),
|
||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||
messageId: msg.id,
|
||
createdAt: msg.createdAt
|
||
})));
|
||
|
||
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
|
||
...m,
|
||
url: getMediaUrl(m.url),
|
||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||
messageId: msg.id,
|
||
createdAt: msg.createdAt
|
||
})));
|
||
|
||
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||
|
||
const renderGrouped = <T extends { createdAt: string }>(
|
||
sortedItems: T[],
|
||
renderItem: (item: T, originalIndex: number) => React.ReactNode,
|
||
gridClass?: string
|
||
) => {
|
||
let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null;
|
||
const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = [];
|
||
|
||
sortedItems.forEach((item, idx) => {
|
||
const date = new Date(item.createdAt);
|
||
const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
|
||
if (currentGroup?.dateStr !== dateStr) {
|
||
currentGroup = { dateStr, items: [] };
|
||
groups.push(currentGroup);
|
||
}
|
||
currentGroup.items.push({item, idx});
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 pb-4 px-1">
|
||
{groups.map((g, i) => (
|
||
<div key={i}>
|
||
<div className="sticky top-0 z-10 bg-black/60 backdrop-blur-md px-3 py-1.5 mb-1.5 shadow-sm border-y border-white/5">
|
||
<span className="text-[10px] font-bold text-knot-300 uppercase tracking-widest">{g.dateStr}</span>
|
||
</div>
|
||
<div className={gridClass || "flex flex-col gap-0.5"}>
|
||
{g.items.map(({item, idx}) => renderItem(item, idx))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const tabsConfig = [
|
||
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
|
||
{ key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length },
|
||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||
];
|
||
|
||
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
|
||
|
||
useEffect(() => {
|
||
if (loadedTabs.size === 4 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||
setActiveTab(availableTabs[0].key);
|
||
}
|
||
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render
|
||
|
||
return (
|
||
<>
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black/60 z-50"
|
||
onClick={onClose}
|
||
/>
|
||
<motion.div
|
||
initial={{ opacity: 0, x: 50, scale: 0.95 }}
|
||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||
<h2 className="text-lg font-semibold text-white">{t('groupSettings')}</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1.5 rounded-xl text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{/* Avatar */}
|
||
<div className="flex-shrink-0 flex flex-col items-center py-6 px-6 overflow-y-auto max-h-[50%] custom-scrollbar">
|
||
<div className="relative group">
|
||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-full blur-[40px] pointer-events-none" />
|
||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||
{chat.avatar ? (
|
||
<img
|
||
src={getMediaUrl(chat.avatar)}
|
||
alt=""
|
||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||
/>
|
||
) : (
|
||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
|
||
{initials}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isAdmin && (
|
||
<>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={avatarUploading}
|
||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||
>
|
||
{avatarUploading ? (
|
||
<Loader2 size={32} className="text-white animate-spin" />
|
||
) : (
|
||
<Camera size={32} className="text-white" />
|
||
)}
|
||
</button>
|
||
{chat.avatar && !avatarUploading && (
|
||
<button
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||
updateChat(updatedChat);
|
||
} catch (e) {
|
||
console.error('Failed to remove avatar', e);
|
||
}
|
||
}}
|
||
className="absolute bottom-0 right-0 p-2 rounded-full bg-red-500/90 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-col items-center gap-2">
|
||
{isEditingName ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={groupName}
|
||
onChange={(e) => setGroupName(e.target.value)}
|
||
className="bg-surface-tertiary border border-accent/30 rounded-xl px-4 py-2 text-lg font-bold text-white text-center focus:outline-none focus:border-accent"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={handleSaveName}
|
||
disabled={isSaving}
|
||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||
>
|
||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||
</button>
|
||
<button
|
||
onClick={() => { setIsEditingName(false); setGroupName(chat.name || ''); }}
|
||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="group/name flex items-center gap-2 cursor-pointer"
|
||
onClick={() => isAdmin && setIsEditingName(true)}
|
||
>
|
||
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||
{chat.name || t('group')}
|
||
</h3>
|
||
{isAdmin && (
|
||
<Edit3 size={16} className="text-knot-400 opacity-0 group-hover/name:opacity-100 transition-opacity" />
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="text-zinc-500 text-sm">
|
||
{chat.members.length} {t('members')}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div className="mt-6 w-full space-y-2">
|
||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">
|
||
{t('groupDescription')}
|
||
</label>
|
||
{isEditingDesc ? (
|
||
<div className="flex items-center gap-2">
|
||
<textarea
|
||
value={groupDesc}
|
||
onChange={(e) => setGroupDesc(e.target.value)}
|
||
className="flex-1 bg-surface-tertiary border border-accent/30 rounded-xl px-3 py-2 text-sm text-white focus:outline-none min-h-[80px]"
|
||
autoFocus
|
||
/>
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
onClick={handleSaveDesc}
|
||
disabled={isSaving}
|
||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||
>
|
||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||
</button>
|
||
<button
|
||
onClick={() => { setIsEditingDesc(false); setGroupDesc(chat.description || ''); }}
|
||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onClick={() => isAdmin && setIsEditingDesc(true)}
|
||
className={`group/desc relative p-3 rounded-xl border border-white/5 bg-white/5 transition-all ${isAdmin ? 'cursor-pointer hover:bg-white/10 hover:border-white/10' : ''}`}
|
||
>
|
||
<p className={`text-sm ${groupDesc ? 'text-zinc-300' : 'text-zinc-600 italic'}`}>
|
||
{groupDesc || t('noDescription')}
|
||
</p>
|
||
{isAdmin && (
|
||
<div className="absolute top-3 right-3 opacity-0 group-hover/desc:opacity-100 transition-opacity">
|
||
<Edit3 size={14} className="text-knot-400" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
|
||
{/* Media / Files / Links Tabs */}
|
||
{availableTabs.length > 0 ? (
|
||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl flex flex-col flex-1 min-h-0">
|
||
<div className="flex border-b border-white/5">
|
||
{availableTabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||
activeTab === tab.key
|
||
? 'bg-white/5 text-knot-400'
|
||
: 'text-zinc-500 hover:text-zinc-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-1.5 mb-0.5">
|
||
<tab.icon size={16} />
|
||
{(loadedTabs.has(tab.key) || tab.key === 'members') && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||
</div>
|
||
<span className="truncate w-full px-1">{tab.label as string}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||
{tabLoading ? (
|
||
<div className="flex items-center justify-center py-10">
|
||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||
</div>
|
||
) : activeTab === 'members' ? (
|
||
<div className="px-4 py-4 pt-2">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h4 className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||
{t('membersCount')}
|
||
</h4>
|
||
{isAdmin && (
|
||
<button
|
||
onClick={() => {
|
||
setShowAddMember(!showAddMember);
|
||
if (!showAddMember) {
|
||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||
}
|
||
}}
|
||
className="flex items-center gap-1 text-xs text-knot-400 hover:text-knot-300 transition-colors"
|
||
>
|
||
<UserPlus size={14} />
|
||
{t('addMember')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Add member search */}
|
||
<AnimatePresence>
|
||
{showAddMember && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
className="overflow-hidden mb-3"
|
||
>
|
||
<div className="relative mb-2">
|
||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder={t('findUser')}
|
||
className="w-full pl-8 pr-3 py-2 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||
/>
|
||
</div>
|
||
{isSearching && (
|
||
<div className="flex justify-center py-2">
|
||
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
||
</div>
|
||
)}
|
||
{searchResults.map((u) => (
|
||
<button
|
||
key={u.id}
|
||
onClick={() => handleAddMember(u.id)}
|
||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||
>
|
||
{u.avatar ? (
|
||
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||
) : (
|
||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div className="flex-1 text-left min-w-0">
|
||
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
|
||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||
</div>
|
||
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
|
||
</button>
|
||
))}
|
||
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
||
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Member list */}
|
||
<div className="space-y-1">
|
||
{[...chat.members]
|
||
.sort((a, b) => {
|
||
if (a.user.id === user?.id) return -1;
|
||
if (b.user.id === user?.id) return 1;
|
||
const aIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(a.role || '');
|
||
const bIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(b.role || '');
|
||
if (aIsAdmin && !bIsAdmin) return -1;
|
||
if (bIsAdmin && !aIsAdmin) return 1;
|
||
return 0;
|
||
})
|
||
.map((member) => (
|
||
<div
|
||
key={member.user.id}
|
||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
|
||
>
|
||
<div className="relative flex-shrink-0">
|
||
{member.user.avatar ? (
|
||
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||
) : (
|
||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||
</div>
|
||
)}
|
||
{member.user.isOnline && (
|
||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm font-medium text-white truncate">
|
||
{member.user.displayName || member.user.username}
|
||
{member.user.id === user?.id && (
|
||
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
||
)}
|
||
</p>
|
||
{['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
||
<Crown size={10} />
|
||
{t('adminBadge') || 'Админ'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
||
</div>
|
||
{isAdmin && member.user.id !== user?.id && !['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||
<button
|
||
onClick={() => handleRemoveMember(member.user.id)}
|
||
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
||
title={t('removeMember')}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'gifs' ? (
|
||
sortedGifs.length > 0 ? (
|
||
renderGrouped(sortedGifs, (m, idx) => (
|
||
<div
|
||
key={m.id}
|
||
onClick={() => {
|
||
const eContext = { stopPropagation: () => {} } as any;
|
||
onGoToMessage?.(m.messageId);
|
||
}}
|
||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||
>
|
||
<video
|
||
src={getMediaUrl(m.url)}
|
||
autoPlay
|
||
loop
|
||
muted
|
||
playsInline
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||
/>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||
>
|
||
{t('showInChat')}
|
||
</button>
|
||
</div>
|
||
), "grid grid-cols-3 gap-0.5 px-1")
|
||
) : (
|
||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||
<p className="text-xs text-zinc-500 italic">Нет GIF файлов</p>
|
||
</div>
|
||
)
|
||
) : activeTab === 'media' ? (
|
||
sortedMedia.length > 0 ? (
|
||
renderGrouped(sortedMedia, (m, idx) => (
|
||
<div
|
||
key={m.id}
|
||
onClick={() => setLightboxIndex(idx)}
|
||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||
>
|
||
{m.type === 'video' ? (
|
||
<>
|
||
<div
|
||
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||
onClick={() => setLightboxIndex(idx)}
|
||
>
|
||
{m.thumbnail ? (
|
||
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||
<Video size={32} className="text-white/20" />
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||
<Play size={24} className="text-white fill-white" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<img
|
||
src={getMediaUrl(m.url)}
|
||
alt=""
|
||
onClick={() => setLightboxIndex(idx)}
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||
/>
|
||
)}
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||
>
|
||
{t('showInChat')}
|
||
</button>
|
||
</div>
|
||
), "grid grid-cols-3 gap-0.5 px-1")
|
||
) : (
|
||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||
</div>
|
||
)
|
||
) : activeTab === 'files' ? (
|
||
sortedFiles.length > 0 ? (
|
||
renderGrouped(sortedFiles, (msg, idx) => (
|
||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||
{(msg.media || []).map((m) => (
|
||
<div key={m.id} className="relative group/file">
|
||
<a
|
||
href={getMediaUrl(m.url)}
|
||
download={m.filename || 'file'}
|
||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||
>
|
||
<div className="w-8 h-8 rounded-lg bg-knot-500/10 flex items-center justify-center flex-shrink-0 text-knot-400">
|
||
<FileText size={16} />
|
||
</div>
|
||
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||
</div>
|
||
<Download size={14} className="text-zinc-600" />
|
||
</a>
|
||
<button
|
||
onClick={() => onGoToMessage?.(msg.id)}
|
||
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||
>
|
||
{t('showInChat')}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||
</div>
|
||
)
|
||
) : (
|
||
sortedLinks.length > 0 ? (
|
||
renderGrouped(sortedLinks, (msg, idx) => (
|
||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5 relative group">
|
||
{msg.links?.map((link, i) => (
|
||
<a
|
||
key={i}
|
||
href={link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-2 text-[13px] text-knot-400 hover:underline truncate mb-1"
|
||
>
|
||
<ExternalLink size={12} className="flex-shrink-0" />
|
||
{link}
|
||
</a>
|
||
))}
|
||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||
<button
|
||
onClick={() => onGoToMessage?.(msg.id)}
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-knot-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
|
||
>
|
||
{t('showInChat')}
|
||
</button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : loadedTabs.size === 4 ? (
|
||
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||
</div>
|
||
) : (
|
||
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</motion.div>
|
||
|
||
<ConfirmModal
|
||
open={!!removeTargetId}
|
||
message={t('confirmRemoveMember')}
|
||
onConfirm={confirmRemoveMember}
|
||
onCancel={() => setRemoveTargetId(null)}
|
||
/>
|
||
|
||
<AnimatePresence>
|
||
{lightboxIndex !== null && (
|
||
<ImageLightbox
|
||
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||
initialIndex={lightboxIndex}
|
||
onClose={() => setLightboxIndex(null)}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{isCropping && cropImage && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
|
||
>
|
||
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
|
||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
|
||
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative w-full h-80 bg-black">
|
||
<Cropper
|
||
image={cropImage}
|
||
crop={crop}
|
||
zoom={zoom}
|
||
aspect={1}
|
||
cropShape="round"
|
||
showGrid={false}
|
||
onCropChange={setCrop}
|
||
onZoomChange={setZoom}
|
||
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-6">
|
||
<div className="flex items-center gap-4 mb-6">
|
||
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
|
||
<input
|
||
type="range"
|
||
value={zoom}
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
onChange={(e) => setZoom(Number(e.target.value))}
|
||
className="flex-1 accent-knot-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 w-full">
|
||
<button
|
||
onClick={() => setIsCropping(false)}
|
||
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||
>
|
||
{t('cancel')}
|
||
</button>
|
||
<button
|
||
onClick={handleCropSave}
|
||
disabled={avatarUploading}
|
||
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||
>
|
||
{avatarUploading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||
{t('save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
</>
|
||
);
|
||
}
|