Files
forkmessager/client-web/src/modules/users/presentation/components/UserProfile.tsx
2026-04-17 00:26:38 +03:00

426 lines
23 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, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
X, MessageSquare, Phone, MoreHorizontal, UserMinus,
UserPlus, UserCheck, Calendar, Info, BellOff,
Image as ImageIcon, Film, FileText, Link as LinkIcon,
Search, ExternalLink, Download
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLang } from '../../../../core/infrastructure/i18n';
import { useAuthStore } from '../../../auth/application/authStore';
import { useFriendStore } from '../../../friends/application/friendStore';
import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { httpClient } from '../../../../core/infrastructure/httpClient';
import { Loader2 } from 'lucide-react';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
interface UserProfileProps {
userId: string;
onClose: () => void;
onMessage?: (userId: string) => void;
isSelf?: boolean;
chatId?: string;
onGoToMessage?: (msgId: string, sequenceId?: number) => void;
}
type TabType = 'media' | 'gif' | 'files' | 'links';
export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelfProp, chatId, onGoToMessage }: UserProfileProps) {
const { t, lang } = useLang();
const { user: currentUser } = useAuthStore();
const { friends, friendRequests, sendRequest, removeFriend, loadFriends } = useFriendStore();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabType>('media');
const [tabData, setTabData] = useState<any[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [countsLoading, setCountsLoading] = useState(true);
const [contentLoading, setContentLoading] = useState(false);
// Lightbox state
const [lightbox, setLightbox] = useState<{ open: boolean; index: number }>({ open: false, index: 0 });
const targetId = useMemo(() => userId?.toLowerCase(), [userId]);
const isMe = useMemo(() => isSelfProp || currentUser?.id?.toLowerCase() === targetId, [isSelfProp, currentUser?.id, targetId]);
const fetchUser = useCallback(async () => {
try {
const res = await httpClient.request<any>(`/profiles/${userId}`);
setUser(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [userId]);
const loadSummary = useCallback(async () => {
if (!chatId) {
setCountsLoading(false);
return;
}
try {
const types: TabType[] = ['media', 'gif', 'files', 'links'];
const data = await Promise.all(
types.map(t => ChatApi.getSharedMedia(chatId, t === 'gif' ? 'gifs' : t))
);
const newCounts: Record<string, number> = {};
types.forEach((t, i) => {
newCounts[t] = data[i].length;
});
setCounts(newCounts);
const available = types.filter(t => newCounts[t] > 0);
if (available.length > 0 && !available.includes(activeTab)) {
setActiveTab(available[0]);
}
} catch (e) {
console.error(e);
} finally {
setCountsLoading(false);
}
}, [chatId, activeTab]);
const loadCurrentTabContent = useCallback(async () => {
if (!chatId) return;
setContentLoading(true);
try {
const data = await ChatApi.getSharedMedia(chatId, activeTab === 'gif' ? 'gifs' : activeTab);
setTabData(data);
} catch (e) {
console.error(e);
} finally {
setContentLoading(false);
}
}, [chatId, activeTab]);
useEffect(() => {
fetchUser();
loadSummary();
loadFriends();
}, [fetchUser, loadSummary, loadFriends]);
useEffect(() => {
loadCurrentTabContent();
}, [loadCurrentTabContent]);
const friend = useMemo(() => friends.find((f: FriendWithId) => f.id?.toLowerCase() === targetId), [friends, targetId]);
const outgoingReq = useMemo(() => friendRequests.find((r: FriendRequest) => r.user?.id?.toLowerCase() === targetId && r.isOutgoing), [friendRequests, targetId]);
const incomingReq = useMemo(() => friendRequests.find((r: FriendRequest) => r.user?.id?.toLowerCase() === targetId && !r.isOutgoing), [friendRequests, targetId]);
const formatRegistrationDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (lang === 'ru') {
const monthsGenitive = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return `${date.getDate()} ${monthsGenitive[date.getMonth()]} ${date.getFullYear()} г.`;
}
return date.toLocaleDateString('en-US', { day: 'numeric', month: 'long', year: 'numeric' });
};
const resolveUrl = (url?: string) => {
if (!url) return '';
if (url.startsWith('http')) return url;
const baseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/api$/, '');
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
};
const formatSize = (bytes?: number) => {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(1)} ${units[i]}`;
};
const handleFriendAction = async () => {
if (friend) {
await removeFriend(friend.friendshipId);
} else if (!outgoingReq && !incomingReq) {
await sendRequest(userId);
}
};
const availableTabs = useMemo(() => {
const all: { id: TabType, icon: any, label: string }[] = [
{ id: 'media', icon: ImageIcon, label: t('mediaTab') },
{ id: 'gif', icon: Film, label: 'GIF' },
{ id: 'files', icon: FileText, label: t('filesTab') },
{ id: 'links', icon: LinkIcon, label: t('linksTab') }
];
return isMe ? all : all.filter(t => counts[t.id] > 0);
}, [counts, lang, isMe, t]);
// Gallery items for Lightbox
const galleryItems = useMemo(() => {
return tabData
.flatMap(item => {
if (!item.media || item.media.length === 0) return [];
return item.media.map((m: any) => ({
id: m.id,
url: resolveUrl(m.url),
type: (m.type?.toLowerCase() === 'video' || m.url.toLowerCase().endsWith('.mp4')) ? 'video' : 'image',
originalItem: item
}));
})
.filter(i => {
if (activeTab === 'media') {
const isGif = i.type === 'video' && (i.url.toLowerCase().endsWith('.mp4') || i.url.toLowerCase().indexOf('klipy') !== -1);
const isStandardImageGif = i.type === 'image' && i.url.toLowerCase().endsWith('.gif');
return !(isGif || isStandardImageGif);
}
return true;
});
}, [tabData, activeTab]);
const openInLightbox = (idx: number) => {
setLightbox({ open: true, index: idx });
};
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[10000] flex items-center justify-center p-4 text-white"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/85 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full lg:max-w-[850px] h-full lg:h-[85vh] bg-[#0a0a0a] lg:rounded-[3rem] border-none lg:border lg:border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
>
<div className="flex items-center justify-between px-8 py-5 border-b border-white/5 bg-white/[0.01]">
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{t('userProfileUpper')}</span>
<button onClick={onClose} className="p-3 rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-all"><X size={24} /></button>
</div>
<div className="flex-1 overflow-y-auto px-8 py-10 custom-scrollbar">
{loading ? (
<div className="flex flex-col items-center justify-center h-full py-20"><Loader2 className="w-12 h-12 text-primary animate-spin mb-4" /></div>
) : (
<>
{/* Profile Card */}
<div className="flex flex-col items-center mb-10">
<div className="relative mb-8 group">
<div className="absolute -inset-4 bg-primary/20 blur-3xl rounded-[3rem] opacity-40 group-hover:opacity-60 transition-opacity" />
<div className="relative w-40 h-40">
<Avatar
src={user.avatar ? resolveUrl(user.avatar) : null}
name={user.displayName || '?'}
size="3xl"
className="shadow-2xl"
/>
</div>
</div>
<h2 className="text-4xl font-black text-white mb-3 tracking-tighter text-center">{user.displayName}</h2>
<div className="flex flex-wrap items-center justify-center gap-4">
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
</div>
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
</div>
{!isMe && (
<div className="mt-10 w-full max-w-[320px]">
{friend ? (
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-500 text-[11px] font-black uppercase tracking-widest hover:bg-red-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserMinus size={18} />{t('removeFriend')}</button>
) : outgoingReq ? (
<button className="w-full py-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-[11px] font-black uppercase tracking-widest cursor-default flex items-center justify-center gap-3 opacity-60"><Loader2 size={16} className="animate-spin" />{t('requestSent')}</button>
) : incomingReq ? (
<button onClick={() => useFriendStore.getState().acceptRequest(incomingReq.id)} className="w-full py-4 rounded-2xl bg-green-500/15 border border-green-500/25 text-green-500 text-[11px] font-black uppercase tracking-widest hover:bg-green-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserCheck size={18} />{t('acceptRequest')}</button>
) : (
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-primary text-white text-[11px] font-black uppercase tracking-widest hover:bg-primary-container transition-all flex items-center justify-center gap-3"><UserPlus size={18} />{t('addFriend')}</button>
)}
</div>
)}
</div>
{/* About Section */}
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
<p className="text-base text-white/80 leading-relaxed font-medium">{user.bio || t('noBio')}</p>
</div>
{/* Tabs Nav */}
{availableTabs.length > 0 && (
<div className="bg-white/[0.03] p-2 rounded-2xl flex items-center mb-8 border border-white/5 shadow-inner gap-2">
{availableTabs.map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`relative flex-1 flex items-center justify-center gap-3 py-4 rounded-2xl transition-all duration-300 ${activeTab === t.id ? 'text-primary bg-white/[0.08] shadow-[0_0_20px_rgba(168,85,247,0.15)]' : 'text-white/20 hover:text-white/60'}`}
>
<t.icon size={20} className="relative z-10" />
<div className="relative z-10 flex items-center gap-2.5">
<span className="text-[12px] font-black uppercase tracking-widest leading-none">{t.label}</span>
{counts[t.id] > 0 && (
<span className="flex items-center justify-center min-w-[24px] h-[22px] px-2 rounded-lg bg-[#1e1e1e] border border-white/10 text-primary text-[11px] font-black shadow-md">
{counts[t.id]}
</span>
)}
</div>
</button>
))}
</div>
)}
{/* Tab Content */}
<div className="min-h-[300px] mb-10">
<AnimatePresence mode="wait">
{contentLoading ? (
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex items-center justify-center py-20"><Loader2 className="w-10 h-10 text-primary/40 animate-spin" /></motion.div>
) : tabData.length === 0 ? (
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center justify-center py-20 opacity-20"><Search size={48} className="mb-4" /><span className="text-sm font-black uppercase tracking-[0.3em]">{t('empty')}</span></motion.div>
) : (
<motion.div key="content" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} className={activeTab === 'media' || activeTab === 'gif' ? "grid grid-cols-3 gap-3" : "flex flex-col gap-3"}>
{(activeTab === 'media' || activeTab === 'gif') ? (
tabData.flatMap((item) => (item.media || []).map((m: any) => {
const mType = m.type?.toLowerCase() || 'image';
const mFilename = m.filename?.toLowerCase() || '';
const isGif = mType === 'gif' || (mType === 'image' && (mFilename.endsWith('.gif') || mFilename.endsWith('.mp4'))) || mFilename.includes('gif') || mFilename.includes('animation') || m.url?.toLowerCase().includes('klipy');
if (activeTab === 'media' && isGif) return null;
if (activeTab === 'gif' && !isGif) return null;
const isVideo = mType === 'video' || m.url?.toLowerCase().endsWith('.mp4');
return (
<div key={`${item.id}-${m.id}`} className="relative aspect-square group/item">
<button
onClick={() => {
const gIdx = galleryItems.findIndex(g => g.id === m.id);
if (gIdx !== -1) openInLightbox(gIdx);
}}
className="w-full h-full rounded-[1.5rem] overflow-hidden border border-white/10 hover:border-primary/50 transition-all bg-zinc-900 group shadow-lg"
>
{isVideo ? (
<div className="w-full h-full relative">
<video src={resolveUrl(m.url)} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
{(activeTab === 'media') && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
)}
</div>
) : (
<img src={resolveUrl(m.url)} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" loading="lazy" />
)}
{activeTab === 'gif' && <div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded-md bg-black/80 text-[8px] font-black text-white uppercase tracking-tighter">GIF</div>}
</button>
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20">
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.sequenceId); }}
className="p-2.5 rounded-2xl bg-primary/95 text-white shadow-xl backdrop-blur-md hover:scale-110 active:scale-95 transition-all"
title={t('showInChat')}
>
<Search size={16} strokeWidth={3} />
</button>
</div>
</div>
);
})).filter(Boolean)
) : (
tabData.map((item, idx) => {
const mediaItem = item.media?.[0];
const linkUrl = item.links && item.links.length > 0 ? item.links[0] : (mediaItem?.url || '');
const mType = mediaItem?.type?.toLowerCase() || 'file';
const mFilename = mediaItem?.filename?.toLowerCase() || '';
const isGif = mType === 'gif' || (mType === 'image' && (mFilename.endsWith('.mp4') || mFilename.endsWith('.gif'))) || mFilename.includes('gif') || mFilename.includes('animation') || (mediaItem?.url?.toLowerCase().includes('klipy'));
if (activeTab === 'files') {
if (!mediaItem) return null;
if (mType === 'image' || mType === 'video' || isGif) return null;
}
if (activeTab === 'links' && (!item.links || item.links.length === 0)) return null;
return (
<div key={idx} className="flex items-center gap-4 p-5 rounded-3xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.05] transition-all group/row relative overflow-hidden">
<div className="p-4 rounded-2xl bg-primary/10 text-primary group-hover/row:scale-110 transition-transform">
{activeTab === 'files' ? <FileText size={24} /> : <LinkIcon size={24} />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-black text-white truncate mb-1">
{activeTab === 'links' ? (item.content || linkUrl) : (mediaItem?.filename || item.content || 'File')}
</div>
<div className="text-xs text-white/40 font-bold uppercase tracking-widest">
{activeTab === 'files' ? formatSize(mediaItem?.size) : (linkUrl?.length > 50 ? linkUrl.slice(0, 50) + '...' : linkUrl)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onGoToMessage?.(item.id, item.sequenceId)}
className="p-3 rounded-2xl bg-white/5 opacity-0 group-hover/row:opacity-100 text-white/40 hover:text-white hover:bg-primary/20 transition-all"
title={t('showInChat')}
>
<MessageSquare size={20} />
</button>
{activeTab === 'files' ? (
<button onClick={() => window.open(resolveUrl(linkUrl), '_blank')} className="p-3 rounded-2xl bg-white/5 hover:bg-primary/20 hover:text-primary transition-all">
<Download size={20} />
</button>
) : (
<button onClick={() => window.open(linkUrl.startsWith('http') ? linkUrl : 'https://' + linkUrl, '_blank')} className="p-3 rounded-2xl bg-white/5 hover:bg-primary/20 hover:text-primary transition-all">
<ExternalLink size={20} />
</button>
)}
</div>
</div>
);
}).filter(Boolean)
)}
</motion.div>
)}
</AnimatePresence>
</div>
</>
)}
</div>
</motion.div>
{/* Gallery Viewer */}
<AnimatePresence>
{lightbox.open && (
<ImageLightbox
images={galleryItems}
initialIndex={lightbox.index}
onClose={() => setLightbox(prev => ({ ...prev, open: false }))}
/>
)}
</AnimatePresence>
</motion.div>,
document.body
);
}