Files
forkmessager/apps/web/src/modules/chats/presentation/components/GroupSettings.tsx
Халимов Рустам fb252f9d87 pre-deep-ddd-refactor
2026-03-19 22:07:23 +03:00

912 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
/>
</>
);
}