426 lines
23 KiB
TypeScript
426 lines
23 KiB
TypeScript
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
|
||
);
|
||
}
|