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

1045 lines
49 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, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react';
import Cropper from 'react-easy-crop';
import { UserApi } from '../../infrastructure/userApi';
import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { FriendApi } from '../../../friends/infrastructure/friendApi';
import { StoryApi } from '../../../stories/infrastructure/storyApi';
import { useAuthStore } from '../../../auth/application/authStore';
import { useLang } from '../../../../lib/i18n';
import { User, Message, FriendshipStatus, StoryGroup } from '../../../../lib/types';
import ConfirmModal from '../../../../components/ConfirmModal';
import ImageLightbox from '../../../../components/ImageLightbox';
import StoryViewer from '../../../../modules/stories/presentation/components/StoryViewer';
import { getSocket } from '../../../../lib/socket';
import { useStoryStore } from '../../../../modules/stories/application/storyStore';
import { getMediaUrl } from '../../../../lib/utils';
import { getCroppedImg } from '../../../../lib/imageCrop';
import DatePicker from '../../../../components/DatePicker';
import { useChatStore } from '../../../chats/application/chatStore';
import { toggleMuteChat, isChatMuted } from '../../../../lib/sounds';
interface UserProfileProps {
userId: string;
chatId?: string;
onClose: () => void;
onGoToMessage?: (messageId: string) => void;
isSelf?: boolean;
}
type MediaTab = 'publications' | 'gifs' | 'media' | 'files' | 'links';
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
const { user: authUser, config } = useAuthStore();
const { t, lang } = useLang();
const [profile, setProfile] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<MediaTab>('publications');
const { chats, addChat, setActiveChat, loadMessages } = useChatStore();
const personalChat = chats.find(c => c.type === 'personal' && c.members.some(m => m.user.id === userId));
const commonGroups = chats.filter(c => c.type === 'group' && c.members.some(m => m.user.id === userId));
const [isMutedLocally, setIsMutedLocally] = useState(() => personalChat ? isChatMuted(personalChat.id) : false);
useEffect(() => {
if (personalChat) setIsMutedLocally(isChatMuted(personalChat.id));
}, [personalChat?.id]);
const handleOpenChat = async () => {
try {
let targetChatId = personalChat?.id;
if (!targetChatId) {
const chat = await ChatApi.createPersonalChat(userId);
addChat(chat);
const socket = getSocket();
if (socket) socket.emit('join_chat', chat.id);
targetChatId = chat.id;
}
setActiveChat(targetChatId);
loadMessages(targetChatId);
onClose();
} catch (e) {
console.error(e);
}
};
const handleToggleMute = () => {
if (!personalChat) return;
const muted = toggleMuteChat(personalChat.id);
setIsMutedLocally(muted);
};
const handleStartCall = (type: 'voice' | 'video') => {
if (profile) {
window.dispatchEvent(new CustomEvent('START_CALL', { detail: { targetUser: profile, type } }));
onClose();
}
};
// Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [tabLoading, setTabLoading] = useState(false);
const [userStories, setUserStories] = useState<any[]>([]); // Changed from StoryGroup | null to any[] as per instruction
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const { openViewer } = useStoryStore();
const [storyViewerOpen, setStoryViewerOpen] = useState(false);
const [initialStoryIdx, setInitialStoryIdx] = useState(0);
// Friend state
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
const [friendLoading, setFriendLoading] = useState(false);
// Profile Edit State
const [isEditing, setIsEditing] = useState(false);
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [birthday, setBirthday] = useState('');
const [isSaving, setIsSaving] = useState(false);
// Avatar edit state
const [isCropping, setIsCropping] = useState(false);
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropImage, setCropImage] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
const API_URL = import.meta.env.VITE_API_URL || '';
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
messageId: msg.id,
createdAt: msg.createdAt
})));
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
messageId: msg.id,
createdAt: msg.createdAt
})));
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
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">
{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>
);
};
useEffect(() => {
loadProfile();
if (!isSelf) {
FriendApi.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
}
}, [userId, isSelf]);
// Load shared media/files/links when tab changes
const loadTabData = useCallback(async (tab: MediaTab) => {
if (loadedTabs.has(tab)) return;
setTabLoading(true);
try {
if (tab === 'publications') {
const data = await StoryApi.getUserStories(userId);
setUserStories(data.stories || []);
} else if (chatId) { // Only load media/files/links if chatId is available
const data = await ChatApi.getSharedMedia(chatId, 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);
}
}, [chatId, userId, loadedTabs]);
useEffect(() => {
const tabsToLoad: MediaTab[] = ['publications'];
if (chatId) {
tabsToLoad.push('gifs', 'media', 'files', 'links');
}
tabsToLoad.forEach(tab => loadTabData(tab));
}, [chatId, loadTabData]);
const loadProfile = async () => {
try {
setIsLoading(true);
if (isSelf && authUser) {
setProfile(authUser);
setDisplayName(authUser.displayName || '');
setBio(authUser.bio || '');
setBirthday(authUser.birthday || '');
} else {
const data = await UserApi.getUser(userId);
setProfile(data);
if (isSelf) {
setDisplayName(data.displayName || '');
setBio(data.bio || '');
setBirthday(data.birthday || '');
}
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
try {
setIsSaving(true);
const dateToSave = birthday ? new Date(birthday).toISOString() : undefined;
const updated = await UserApi.updateProfile({
displayName: displayName.trim(),
bio: bio.trim(),
birthday: dateToSave,
});
setProfile(updated);
useAuthStore.getState().updateUser(updated);
setIsEditing(false);
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleSendFriendRequest = async () => {
try {
setFriendLoading(true);
const result = await FriendApi.sendFriendRequest(userId);
if (result.status === 'accepted') {
setFriendStatus({ status: 'accepted', friendshipId: null });
} else {
setFriendStatus({ status: 'pending', friendshipId: null, direction: 'outgoing' });
}
// Notify via socket
const socket = getSocket();
if (socket) socket.emit('friend_request', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const handleAcceptFriend = async () => {
if (!friendStatus?.friendshipId) return;
try {
setFriendLoading(true);
await FriendApi.acceptFriendRequest(friendStatus.friendshipId);
setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId });
const socket = getSocket();
if (socket) socket.emit('friend_accepted', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const handleRemoveFriend = async () => {
if (!friendStatus?.friendshipId) return;
try {
setFriendLoading(true);
await FriendApi.removeFriend(friendStatus.friendshipId);
setFriendStatus({ status: 'none', friendshipId: null });
const socket = getSocket();
if (socket) socket.emit('friend_removed', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setCropFile(file);
const url = URL.createObjectURL(file);
setCropImage(url);
setIsCropping(true);
};
const handleCropSave = async () => {
if (!cropImage || !croppedAreaPixels) return;
try {
setTabLoading(true); // Reusing tabLoading for avatar upload
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
if (!croppedFile) throw new Error("Could not crop image");
const updatedUser = await UserApi.uploadAvatar(croppedFile);
setProfile(updatedUser);
useAuthStore.getState().updateUser(updatedUser);
setIsCropping(false);
setCropImage(null);
setCropFile(null);
setCroppedAreaPixels(null);
setCrop({ x: 0, y: 0 });
setZoom(1);
} catch (e) {
console.error('Failed to save avatar', e);
} finally {
setTabLoading(false);
}
};
const handleRemoveAvatar = async () => {
try {
setTabLoading(true);
await UserApi.removeAvatar();
const updatedUser = { ...profile!, avatar: null };
setProfile(updatedUser);
useAuthStore.getState().updateUser({ avatar: null });
} catch (err) {
console.error(err);
} finally {
setTabLoading(false);
}
};
const initials = (profile?.displayName || profile?.username || '??')
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
const tabsConfig = [
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length },
...(chatId ? [
{ 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 => !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => {
const expected = chatId ? 5 : 1;
if (loadedTabs.size === expected && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
setActiveTab(availableTabs[0].key as any);
}
}, [loadedTabs, activeTab, chatId, availableTabs]);
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 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
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"
>
{/* Шапка */}
<div className="flex items-center justify-between p-5 border-b border-border/40 bg-surface-secondary relative overflow-hidden">
<h2 className="text-lg font-semibold tracking-tight text-white relative z-10 flex-1">
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
</h2>
<div className="flex items-center gap-2 relative z-10">
{isSelf && (
!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5"
>
<Edit3 size={16} />
</button>
) : (
<button
onClick={handleSave}
disabled={isSaving}
className="w-8 h-8 flex items-center justify-center rounded-full bg-knot-500/20 text-knot-400 hover:text-knot-300 hover:bg-knot-500/30 transition-all border border-knot-500/30"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
</button>
)
)}
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5"
>
<X size={18} />
</button>
</div>
</div>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
</div>
) : profile ? (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-shrink-0 overflow-y-auto max-h-[50%] custom-scrollbar">
{/* Аватар */}
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
<div className="relative group">
<div className="relative">
{profile.avatar ? (
<img
src={getMediaUrl(profile.avatar)}
alt=""
className="w-32 h-32 rounded-full object-cover border-4 border-surface bg-surface"
/>
) : (
<div className="w-32 h-32 rounded-full bg-accent flex items-center justify-center text-white font-bold text-4xl border-4 border-surface relative overflow-hidden">
<span className="relative z-10 drop-shadow-sm">{initials}</span>
</div>
)}
</div>
{isSelf && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform -translate-y-2 group-hover:translate-y-0 z-30">
<label className="w-9 h-9 bg-accent hover:bg-accent-light text-white rounded-full border-2 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110">
<Camera size={16} />
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleAvatarSelect}
/>
</label>
</div>
)}
{isSelf && profile.avatar && (
<button
onClick={handleRemoveAvatar}
className="absolute w-9 h-9 -bottom-2 left-1/2 -translate-x-1/2 bg-red-500/90 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)] border border-red-400/30 transform translate-y-2 group-hover:translate-y-0 z-30 hover:scale-110"
title={t('removePhoto') as string}
>
<Trash2 size={16} className="text-white" />
</button>
)}
</div>
{/* Имя */}
{isEditing ? (
<div className="mt-5 w-full max-w-[260px] relative">
<div className="absolute -inset-0.5 bg-gradient-to-r from-knot-500 to-purple-500 rounded-2xl opacity-50 blur-sm pointer-events-none" />
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('enterName')}
className="relative text-lg font-bold text-center text-white bg-black/40 border border-white/20 outline-none px-4 py-2.5 w-full rounded-2xl transition-colors focus:bg-black/60 focus:border-knot-400 placeholder-white/30 truncate"
/>
</div>
) : (
<h3 className="mt-5 text-[24px] font-semibold text-white tracking-tight text-center px-4">
{profile.displayName || profile.username}
</h3>
)}
{/* Username (неизменяемый) */}
<div className="flex items-center gap-1.5 mt-2 bg-accent/10 px-4 py-1.5 rounded-full border border-accent/20 cursor-default">
<AtSign size={14} className="text-accent" />
<span className="text-sm font-medium text-accent">{profile.username}</span>
</div>
{/* Онлайн статус */}
<p className="text-xs font-semibold uppercase tracking-widest mt-4">
{profile.isOnline ? (
<span className="text-emerald-400 drop-shadow-[0_0_8px_rgba(52,211,153,0.8)] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
{t('online')}
</span>
) : (
<span className="text-zinc-500 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full" />
{t('wasRecently')}
</span>
)}
</p>
{/* Friend button (for other users only) */}
{!isSelf && friendStatus && (
<div className="mt-4">
{friendStatus.status === 'none' && (
<button
onClick={handleSendFriendRequest}
disabled={friendLoading}
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-knot-500/20 border border-knot-500/30 text-knot-300 hover:bg-knot-500/30 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} />}
{t('addFriend')}
</button>
)}
{friendStatus.status === 'pending' && friendStatus.direction === 'outgoing' && (
<div className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm font-medium">
<Clock size={16} />
{t('requestSent')}
</div>
)}
{friendStatus.status === 'pending' && friendStatus.direction === 'incoming' && (
<div className="flex gap-2">
<button
onClick={handleAcceptFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-green-500/20 border border-green-500/30 text-green-400 hover:bg-green-500/30 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserCheck size={16} />}
{t('accept')}
</button>
<button
onClick={handleRemoveFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
>
{t('decline')}
</button>
</div>
)}
{friendStatus.status === 'accepted' && (
<button
onClick={handleRemoveFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserMinus size={16} />}
{t('removeFriend')}
</button>
)}
</div>
)}
{/* Быстрые действия (только для других пользователей) */}
{!isSelf && (
<div className="flex w-full items-center justify-center gap-2 mt-6 px-4">
<button
onClick={handleOpenChat}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MessageSquare size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('chat')}</span>
</button>
<button
onClick={handleToggleMute}
disabled={!personalChat}
className={`flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl transition-colors border ${!personalChat ? 'opacity-50 cursor-not-allowed bg-white/5 border-white/5' : 'bg-white/5 hover:bg-white/10 border-white/5'}`}
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
{isMutedLocally ? <BellOff size={16} className="text-white" /> : <Bell size={16} className="text-white" />}
</div>
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
</button>
{config?.enableCalls && (
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
)}
<button
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MoreHorizontal size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('more')}</span>
</button>
</div>
)}
</div>
{/* Информация */}
<div className="px-5 space-y-3 pb-8 relative z-10">
{/* О себе */}
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-knot-500/20 flex items-center justify-center border border-knot-500/30">
<Edit3 size={12} className="text-knot-400" />
</div>
<label className="text-xs font-semibold text-knot-200/50 uppercase tracking-widest">
{t('aboutMe')}
</label>
</div>
{isEditing ? (
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
className="w-full rounded-xl bg-black/40 text-sm text-white placeholder-white/30 p-3 border border-white/10 focus:border-knot-500 transition-colors resize-none outline-none leading-relaxed"
placeholder={t('tellAboutYourself')}
/>
) : (
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
{profile.bio || (
<span className="text-white/30 italic">{t('notSpecified')}</span>
)}
</p>
)}
</div>
{/* Дата рождения */}
{(profile.birthday || isEditing) && (
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
<Calendar size={12} className="text-orange-400" />
</div>
<label className="text-xs font-semibold text-orange-200/50 uppercase tracking-widest">
{t('birthday')}
</label>
</div>
{isEditing ? (
<DatePicker value={birthday} onChange={setBirthday} />
) : (
<p className="text-sm text-zinc-200 pl-1">
{profile.birthday ? (
new Date(profile.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
) : (
<span className="text-white/30 italic">{t('notSpecified')}</span>
)}
</p>
)}
</div>
)}
{/* Дата регистрации */}
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
<Check size={12} className="text-emerald-400" />
</div>
<label className="text-xs font-semibold text-emerald-200/50 uppercase tracking-widest">
{t('onKnotSince')}
</label>
</div>
<p className="text-sm text-zinc-200 pl-1">
{new Date(profile.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</div>
{/* Общие группы */}
{!isSelf && commonGroups.length > 0 && (
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group mt-2 text-left">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-purple-500/20 flex items-center justify-center border border-purple-500/30">
<Users size={12} className="text-purple-400" />
</div>
<label className="text-xs font-semibold text-purple-200/50 uppercase tracking-widest">
{t('commonGroups')}
</label>
</div>
<div className="flex flex-col gap-1.5">
{commonGroups.map(cg => (
<button key={cg.id} onClick={() => { setActiveChat(cg.id); loadMessages(cg.id); onClose(); }} className="flex items-center gap-3 w-full bg-white/5 hover:bg-white/10 p-2 rounded-xl transition-colors border border-white/5">
{cg.avatar ? (
<img src={getMediaUrl(cg.avatar)} className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex flex-col items-center justify-center text-white font-bold">{(cg.name || 'G').charAt(0)}</div>
)}
<div className="flex-1 text-left min-w-0">
<p className="text-sm font-medium text-white truncate">{cg.name || 'Group'}</p>
<p className="text-xs text-zinc-500 truncate">{cg.members.length} {t('participants')}</p>
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Медиа / Файлы / Ссылки */}
{availableTabs.length > 0 ? (
<div className="flex flex-col flex-1 min-h-0 border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
<div className="flex-shrink-0 flex border-b border-white/5 h-14">
{availableTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
activeTab === tab.key
? 'bg-white/5 text-knot-400'
: 'text-zinc-500 hover:text-white/70'
}`}
>
<div className="flex items-center gap-1.5 mb-0.5">
<tab.icon size={16} />
{loadedTabs.has(tab.key) && <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">
{tabLoading ? (
<div className="flex items-center justify-center py-10 text-zinc-500">
<div className="w-6 h-6 border-2 border-current border-t-transparent rounded-full animate-spin" />
</div>
) : activeTab === 'publications' ? (
sortedStories.length > 0 ? (
renderGrouped(sortedStories, (s, idx) => (
<div
key={s.id}
onClick={() => {
setInitialStoryIdx(idx);
setStoryViewerOpen(true);
}}
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden cursor-pointer group rounded-sm"
>
{s.type === 'video' ? (
<div className="w-full h-full relative">
{s.mediaUrl && <video key={s.id} src={getMediaUrl(s.mediaUrl)} className="w-full h-full object-cover opacity-60" />}
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Play size={24} className="text-white fill-white opacity-80" />
</div>
</div>
) : s.type === 'image' && s.mediaUrl ? (
<img
key={s.id}
src={getMediaUrl(s.mediaUrl)}
alt=""
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 opacity-80"
/>
) : (
<div className="w-full h-full p-2 flex items-center justify-center text-center overflow-hidden" style={{ background: s.bgColor || '#6366f1' }}>
<p className="text-[10px] font-bold text-white line-clamp-4">{s.content}</p>
</div>
)}
<div className="absolute bottom-1 right-1 bg-black/40 backdrop-blur-sm px-1.5 rounded flex items-center gap-0.5 scale-75 origin-bottom-right">
<Eye size={10} className="text-white/70" />
<span className="text-[10px] text-white font-medium">{s.viewCount}</span>
</div>
</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">Нет публикаций</p>
</div>
)
) : activeTab === 'media' ? (
sortedMedia.length > 0 ? (
renderGrouped(sortedMedia, (m, idx) => (
<div
key={m.id}
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
>
{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"
/>
)}
{/* Navigation button */}
<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-8">
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</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-10 h-10 rounded-xl bg-knot-500/20 flex items-center justify-center flex-shrink-0 border border-knot-500/30 group-hover/file:scale-105 transition-transform">
<FileText size={18} className="text-knot-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
<p className="text-xs text-zinc-500">
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
</p>
</div>
<Download size={16} className="text-zinc-500 flex-shrink-0" />
</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-8">
<p className="text-xs text-zinc-600 italic">{t('sharedFiles') as string}</p>
</div>
)
) : (
sortedLinks.length > 0 ? (
renderGrouped(sortedLinks, (msg, idx) => (
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group border-b border-white/5">
<p className="text-xs text-zinc-500 mb-1.5 font-medium pr-8">
{msg.sender?.displayName || msg.sender?.username}
</p>
{(msg.links || []).map((link: string, i: number) => (
<a
key={i}
href={link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-knot-400 hover:text-knot-300 transition-colors truncate"
>
<ExternalLink size={14} className="flex-shrink-0" />
<span className="truncate">{link}</span>
</a>
))}
{msg.content && (
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{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-8">
<p className="text-xs text-zinc-600 italic">{t('sharedLinks') as string}</p>
</div>
)
)}
</div>
</div>
) : loadedTabs.size === (chatId ? 5 : 1) ? (
<div className="m-4 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="m-4 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<div className="w-6 h-6 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-zinc-500">
{t('profileNotFound')}
</div>
)}
</motion.div>
{/* Media lightbox gallery */}
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</AnimatePresence>
{storyViewerOpen && profile && (
<StoryViewer
stories={[{
user: {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
avatar: profile.avatar
},
stories: sortedStories,
hasUnviewed: false
}]}
initialUserIndex={0}
initialStoryIndex={initialStoryIdx}
onClose={() => setStoryViewerOpen(false)}
onRefresh={() => {
setLoadedTabs(prev => {
const n = new Set(prev);
n.delete('publications');
return n;
});
loadTabData('publications');
}}
/>
)}
{/* Avatar Cropper Modal */}
<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}
aria-labelledby="Zoom"
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={tabLoading}
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"
>
{tabLoading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
{t('save')}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={allMedia.map(m => ({ url: m.url, type: m.type }))}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</AnimatePresence>
</>
);
}