1045 lines
49 KiB
TypeScript
1045 lines
49 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|