Files
forkmessager/client-web/src/modules/chats/presentation/components/GroupSettings.tsx
T

910 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 '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import { Chat, UserPresence, Message } from '../../../../core/domain/types';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import { getMediaUrl } from '../../../../core/utils/utils';
import { getCroppedImg } from '../../../../core/infrastructure/imageCrop';
interface GroupSettingsProps {
chat: Chat;
onClose: () => void;
onGoToMessage?: (messageId: string, sequenceId?: number) => 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,
sequenceId: msg.sequenceId
})));
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,
sequenceId: msg.sequenceId
})));
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: '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 inset-0 lg:inset-auto lg:right-3 lg:top-3 lg:bottom-3 w-full lg:w-[900px] lg:max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border-none lg:border lg:border-white/5 lg: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-[3rem] blur-[40px] pointer-events-none" />
<div className="relative z-10 p-1.5 rounded-[2.5rem] bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
<Avatar
src={chat.avatar ? getMediaUrl(chat.avatar) : null}
name={chat.name || '?'}
size="2xl"
className="shadow-inner"
/>
</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-2 py-4 text-[10px] font-black uppercase tracking-wider transition-all ${
activeTab === tab.key
? 'bg-white/10 text-knot-400 shadow-[inset_0_-2px_0_0_#A855F7]'
: 'text-zinc-500 hover:text-zinc-400'
}`}
>
<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"
>
<Avatar
src={u.avatar ? getMediaUrl(u.avatar) : null}
name={u.displayName || u.username || '?'}
size="sm"
online={u.isOnline}
/>
<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">
<Avatar
src={member.user.avatar ? getMediaUrl(member.user.avatar) : null}
name={member.user.displayName || member.user.username || '?'}
size="sm"
online={member.user.isOnline}
/>
</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={() => setLightboxIndex(idx)}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
>
{(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
<img
src={getMediaUrl(m.url)}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<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, m.sequenceId); }}
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" />
) : (
<video src={getMediaUrl(m.url)} preload="metadata" className="w-full h-full object-cover bg-zinc-900" />
)}
<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, m.sequenceId); }}
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, msg.sequenceId)}
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, msg.sequenceId)}
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={(activeTab === 'gifs' ? sortedGifs : sortedMedia).map((m) => ({
url: m.url, // Already contains getMediaUrl in calculated allGifs/allMedia
type: activeTab === 'gifs' ? 'gif' : 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}
/>
</>
);
}