Доработки профиля
This commit is contained in:
@@ -123,7 +123,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
socket.on('group_call_active', handler);
|
||||
// Request current status when opening a group chat
|
||||
if (activeChat && chat?.type === 'group') {
|
||||
socket.emit('group_call_status', { chatId: activeChat });
|
||||
socket.emit('get_group_call_status', { chatId: activeChat });
|
||||
}
|
||||
return () => { socket.off('group_call_active', handler); };
|
||||
}, [activeChat, user?.id, chat?.type]);
|
||||
|
||||
@@ -4,14 +4,19 @@ import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
interface TenorGif {
|
||||
interface KlipyGif {
|
||||
id: string;
|
||||
media_formats?: {
|
||||
gif?: { url: string };
|
||||
tinygif?: { url: string };
|
||||
images: {
|
||||
original: { url: string };
|
||||
fixed_height_small: { url: string };
|
||||
};
|
||||
content_description?: string;
|
||||
file: {
|
||||
sd?: { gif?: { url: string }; webp?: { url: string } };
|
||||
hd?: { gif?: { url: string }; webp?: { url: string } };
|
||||
};
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface EmojiPickerProps {
|
||||
@@ -20,36 +25,65 @@ interface EmojiPickerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getTenorKey = () => localStorage.getItem('vortex_tenor_key') || '';
|
||||
const getKlipyKey = () => import.meta.env.VITE_KLIPY_API_KEY || '';
|
||||
const getCustomerId = () => {
|
||||
const user = useAuthStore.getState().user;
|
||||
return user?.id || 'anonymous';
|
||||
};
|
||||
|
||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||
const { lang, t } = useLang();
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||
const [gifQuery, setGifQuery] = useState('');
|
||||
const [gifs, setGifs] = useState<TenorGif[]>([]);
|
||||
const [gifs, setGifs] = useState<KlipyGif[]>([]);
|
||||
const [gifLoading, setGifLoading] = useState(false);
|
||||
const [trendingGifs, setTrendingGifs] = useState<TenorGif[]>([]);
|
||||
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Load trending GIFs
|
||||
// Helper to safely extract GIF array from various possible Klipy API responses
|
||||
const extractGifs = (d: any): KlipyGif[] => {
|
||||
if (!d) return [];
|
||||
if (Array.isArray(d)) return d;
|
||||
if (Array.isArray(d.data)) return d.data;
|
||||
if (Array.isArray(d.result)) return d.result;
|
||||
if (d.result && Array.isArray(d.result.data)) return d.result.data;
|
||||
if (Array.isArray(d.gifs)) return d.gifs;
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load trending GIFs (Klipy)
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && getTenorKey() && trendingGifs.length === 0) {
|
||||
if (tab === 'gif' && getKlipyKey() && trendingGifs.length === 0) {
|
||||
setGifLoading(true);
|
||||
fetch(`https://tenor.googleapis.com/v2/featured?key=${getTenorKey()}&limit=30&media_filter=gif,tinygif`)
|
||||
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/trending?customer_id=${getCustomerId()}&per_page=30`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setTrendingGifs(d.results || []); setGifLoading(false); })
|
||||
.catch(() => setGifLoading(false));
|
||||
.then(d => {
|
||||
setTrendingGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Klipy trending error:', e);
|
||||
setTrendingGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab]);
|
||||
}, [tab, trendingGifs.length]);
|
||||
|
||||
const searchGifs = useCallback((q: string) => {
|
||||
if (!getTenorKey() || !q.trim()) { setGifs([]); return; }
|
||||
if (!getKlipyKey() || !q.trim()) { setGifs([]); return; }
|
||||
setGifLoading(true);
|
||||
fetch(`https://tenor.googleapis.com/v2/search?key=${getTenorKey()}&q=${encodeURIComponent(q)}&limit=30&media_filter=gif,tinygif`)
|
||||
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/search?customer_id=${getCustomerId()}&q=${encodeURIComponent(q)}&per_page=30`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setGifs(d.results || []); setGifLoading(false); })
|
||||
.catch(() => setGifLoading(false));
|
||||
.then(d => {
|
||||
setGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Klipy search error:', e);
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGifSearch = (q: string) => {
|
||||
@@ -58,9 +92,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||
};
|
||||
|
||||
const pickGif = (gif: TenorGif) => {
|
||||
const url = gif.media_formats?.gif?.url || gif.media_formats?.tinygif?.url || '';
|
||||
const preview = gif.media_formats?.tinygif?.url || url;
|
||||
const pickGif = (gif: KlipyGif) => {
|
||||
const url = gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url || '';
|
||||
const preview = gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || url;
|
||||
if (onSelectGif && url) {
|
||||
onSelectGif(url, preview);
|
||||
}
|
||||
@@ -112,7 +146,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
>
|
||||
EMOJI
|
||||
</button>
|
||||
{(getTenorKey() || onSelectGif) && (
|
||||
{(getKlipyKey() || onSelectGif) && (
|
||||
<button
|
||||
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
||||
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
@@ -144,12 +178,12 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
{/* GIF tab */}
|
||||
{tab === 'gif' && (
|
||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||
{!getTenorKey() ? (
|
||||
{!getKlipyKey() ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-zinc-400 mb-2">{t('tenorKeyRequired')}</p>
|
||||
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
|
||||
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
|
||||
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-vortex-400">
|
||||
localStorage.setItem('vortex_tenor_key', 'YOUR_KEY')
|
||||
VITE_KLIPY_API_KEY in .env
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
@@ -188,8 +222,8 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||
alt={gif.content_description || 'GIF'}
|
||||
src={gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || gif.file?.hd?.gif?.url}
|
||||
alt={gif.title || 'GIF'}
|
||||
className="w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Min
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { getMediaUrl } from '../lib/utils';
|
||||
import { api } from '../lib/api';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
@@ -13,6 +14,8 @@ interface ParticipantInfo {
|
||||
displayName?: string;
|
||||
avatar?: string | null;
|
||||
isSharingScreen?: boolean;
|
||||
isMuted?: boolean;
|
||||
isVideoOff?: boolean;
|
||||
}
|
||||
|
||||
interface PeerState {
|
||||
@@ -234,22 +237,28 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const toggleMic = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
|
||||
setIsMuted(m => !m);
|
||||
const newMuted = !isMuted;
|
||||
setIsMuted(newMuted);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_status', { chatId, isMuted: newMuted, isVideoOff });
|
||||
}
|
||||
}, []);
|
||||
}, [isMuted, isVideoOff, chatId]);
|
||||
|
||||
// Toggle video
|
||||
const toggleVideo = useCallback(async () => {
|
||||
let newVideoOff = isVideoOff;
|
||||
if (!isVideoOff) {
|
||||
// Turn off video
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
|
||||
}
|
||||
newVideoOff = true;
|
||||
setIsVideoOff(true);
|
||||
} else {
|
||||
// Turn on video
|
||||
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
|
||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
|
||||
newVideoOff = false;
|
||||
setIsVideoOff(false);
|
||||
} else {
|
||||
try {
|
||||
@@ -265,12 +274,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||
}
|
||||
newVideoOff = false;
|
||||
setIsVideoOff(false);
|
||||
}
|
||||
} catch { console.warn('Camera unavailable'); }
|
||||
}
|
||||
}
|
||||
}, [isVideoOff]);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: newVideoOff });
|
||||
}, [isVideoOff, isMuted, chatId]);
|
||||
|
||||
// Toggle screen share
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
@@ -648,6 +660,21 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
});
|
||||
};
|
||||
|
||||
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: boolean; isVideoOff: boolean }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
const p = next.get(data.userId);
|
||||
if (p) next.set(data.userId, { ...p, isMuted: data.isMuted, isVideoOff: data.isVideoOff });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onRequestStatus = (data: { chatId: string; requestedBy: string }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
socket.emit('group_call_status', { chatId, isMuted, isVideoOff });
|
||||
};
|
||||
|
||||
socket.on('group_call_participants', onParticipants);
|
||||
socket.on('group_call_user_joined', onUserJoined);
|
||||
socket.on('group_call_user_left', onUserLeft);
|
||||
@@ -658,6 +685,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
socket.on('screen_share_started', onScreenShareStarted);
|
||||
socket.on('screen_share_stopped', onScreenShareStopped);
|
||||
socket.on('group_call_status_updated', onStatusUpdated);
|
||||
socket.on('group_call_request_status', onRequestStatus);
|
||||
|
||||
return () => {
|
||||
socket.off('group_call_participants', onParticipants);
|
||||
@@ -670,6 +699,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
socket.off('screen_share_started', onScreenShareStarted);
|
||||
socket.off('screen_share_stopped', onScreenShareStopped);
|
||||
socket.off('group_call_status_updated', onStatusUpdated);
|
||||
socket.off('group_call_request_status', onRequestStatus);
|
||||
};
|
||||
}, [isOpen, chatId, createPeerConnection]);
|
||||
|
||||
@@ -871,7 +902,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
|
||||
return (
|
||||
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
|
||||
{hasVid ? (
|
||||
{hasVid && !p.isVideoOff ? (
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
@@ -886,16 +917,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
{p.avatar ? (
|
||||
<img src={p.avatar} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
||||
<img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{p.isMuted && <MicOff size={14} className="text-red-400" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%]">
|
||||
{p.displayName || p.username}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
|
||||
{p.displayName || p.username} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,25 @@ import {
|
||||
Search,
|
||||
Crown,
|
||||
Users,
|
||||
ImageIcon,
|
||||
FileText,
|
||||
Link as LinkIcon,
|
||||
Play,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Video
|
||||
} from 'lucide-react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { Chat, UserPresence } from '../lib/types';
|
||||
import { Chat, UserPresence, Message } from '../lib/types';
|
||||
import Avatar from './Avatar';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
import { getMediaUrl } from '../lib/utils';
|
||||
import { getCroppedImg } from '../lib/imageCrop';
|
||||
|
||||
interface GroupSettingsProps {
|
||||
chat: Chat;
|
||||
@@ -31,16 +43,33 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
|
||||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||
const isAdmin = currentMember?.role === 'admin';
|
||||
const isAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(currentMember?.role || '');
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||
const [groupName, setGroupName] = useState(chat.name || '');
|
||||
const [groupDesc, setGroupDesc] = useState(chat.description || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'media' | 'files' | 'links'>('media');
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Cropping states
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -48,7 +77,8 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
// Keep local state in sync with chat prop
|
||||
useEffect(() => {
|
||||
setGroupName(chat.name || '');
|
||||
}, [chat.name]);
|
||||
setGroupDesc(chat.description || '');
|
||||
}, [chat.name, chat.description]);
|
||||
|
||||
// Search users to add
|
||||
useEffect(() => {
|
||||
@@ -86,6 +116,53 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDesc = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updatedChat = await api.updateGroup(chat.id, { description: groupDesc.trim() });
|
||||
updateChat(updatedChat);
|
||||
setIsEditingDesc(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setCropImage(reader.result as string);
|
||||
setCropFile(file);
|
||||
setIsCropping(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropSave = async () => {
|
||||
if (!cropImage || !croppedAreaPixels) return;
|
||||
setAvatarUploading(true);
|
||||
try {
|
||||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||
if (!croppedFile) throw new Error("Could not crop image");
|
||||
|
||||
const updatedChat = await api.uploadGroupAvatar(chat.id, croppedFile);
|
||||
|
||||
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
||||
setIsCropping(false);
|
||||
setCropImage(null);
|
||||
setCropFile(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to crop group avatar:', err);
|
||||
alert(t('error'));
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -146,6 +223,34 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const loadTabData = async (tab: 'media' | 'files' | 'links') => {
|
||||
if (loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await api.getSharedMedia(chat.id, tab);
|
||||
if (tab === 'media') setSharedMedia(data);
|
||||
else if (tab === 'files') setSharedFiles(data);
|
||||
else setSharedLinks(data);
|
||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||
} catch (e) {
|
||||
console.error('Failed to load shared', tab, e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: getMediaUrl(m.url),
|
||||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||||
messageId: msg.id
|
||||
})));
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
@@ -160,7 +265,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[380px] 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"
|
||||
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"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||
@@ -181,7 +286,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||
{chat.avatar ? (
|
||||
<img
|
||||
src={chat.avatar}
|
||||
src={getMediaUrl(chat.avatar)}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||||
/>
|
||||
@@ -200,84 +305,253 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||
>
|
||||
{avatarUploading ? (
|
||||
<Loader2 size={24} className="text-white animate-spin" />
|
||||
<Loader2 size={32} className="text-white animate-spin" />
|
||||
) : (
|
||||
<Camera size={24} className="text-white" />
|
||||
<Camera size={32} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
{chat.avatar && (
|
||||
{chat.avatar && !avatarUploading && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={avatarUploading}
|
||||
className="absolute -top-1 -right-1 w-7 h-7 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const updatedChat = await api.removeGroupAvatar(chat.id);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error('Failed to remove avatar', e);
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-0 right-0 p-2 rounded-full bg-red-500/90 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
||||
>
|
||||
<X size={14} className="text-white" />
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group name */}
|
||||
{isEditingName ? (
|
||||
<div className="mt-4 flex items-center gap-2 w-full max-w-[260px]">
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="flex-1 text-lg font-bold text-center text-white bg-transparent border-b border-vortex-500 outline-none px-2 py-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveName();
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditingName(false);
|
||||
setGroupName(chat.name || '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving || !groupName.trim()}
|
||||
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditingName(false);
|
||||
setGroupName(chat.name || '');
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-white">{chat.name}</h3>
|
||||
{isAdmin && (
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="bg-surface-tertiary border border-accent/30 rounded-xl px-4 py-2 text-lg font-bold text-white text-center focus:outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="p-1 rounded-lg text-zinc-500 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving}
|
||||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsEditingName(false); setGroupName(chat.name || ''); }}
|
||||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="group/name flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => isAdmin && setIsEditingName(true)}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||
{chat.name || t('group')}
|
||||
</h3>
|
||||
{isAdmin && (
|
||||
<Edit3 size={16} className="text-vortex-400 opacity-0 group-hover/name:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-zinc-500 text-sm">
|
||||
{chat.members.length} {t('members')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mt-6 w-full space-y-2">
|
||||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">
|
||||
{t('groupDescription')}
|
||||
</label>
|
||||
{isEditingDesc ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<textarea
|
||||
value={groupDesc}
|
||||
onChange={(e) => setGroupDesc(e.target.value)}
|
||||
className="flex-1 bg-surface-tertiary border border-accent/30 rounded-xl px-3 py-2 text-sm text-white focus:outline-none min-h-[80px]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleSaveDesc}
|
||||
disabled={isSaving}
|
||||
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsEditingDesc(false); setGroupDesc(chat.description || ''); }}
|
||||
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => isAdmin && setIsEditingDesc(true)}
|
||||
className={`group/desc relative p-3 rounded-xl border border-white/5 bg-white/5 transition-all ${isAdmin ? 'cursor-pointer hover:bg-white/10 hover:border-white/10' : ''}`}
|
||||
>
|
||||
<p className={`text-sm ${groupDesc ? 'text-zinc-300' : 'text-zinc-600 italic'}`}>
|
||||
{groupDesc || t('noDescription')}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover/desc:opacity-100 transition-opacity">
|
||||
<Edit3 size={14} className="text-vortex-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-zinc-400 mt-1 flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{chat.members.length} {t('members')}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Media / Files / Links Tabs */}
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl">
|
||||
<div className="flex border-b border-white/5">
|
||||
{[
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||
].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-vortex-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span className="truncate w-full px-1">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[200px] max-h-[300px] overflow-y-auto">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
allMedia.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{allMedia.map((m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
>
|
||||
{m.thumbnail ? (
|
||||
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sharedFiles.length > 0 ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{sharedFiles.flatMap((msg) =>
|
||||
(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-vortex-500/10 flex items-center justify-center flex-shrink-0 text-vortex-400">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||||
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||||
</div>
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sharedLinks.length > 0 ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{sharedLinks.map((msg) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors">
|
||||
{msg.links?.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[13px] text-vortex-400 hover:underline truncate mb-1"
|
||||
>
|
||||
<ExternalLink size={12} className="flex-shrink-0" />
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
@@ -334,7 +608,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
@@ -356,10 +630,14 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
|
||||
{/* Member list */}
|
||||
<div className="space-y-1">
|
||||
{chat.members
|
||||
{[...chat.members]
|
||||
.sort((a, b) => {
|
||||
if (a.role === 'admin' && b.role !== 'admin') return -1;
|
||||
if (b.role === 'admin' && a.role !== 'admin') return 1;
|
||||
if (a.user.id === user?.id) return -1;
|
||||
if (b.user.id === user?.id) return 1;
|
||||
const aIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(a.role || '');
|
||||
const bIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(b.role || '');
|
||||
if (aIsAdmin && !bIsAdmin) return -1;
|
||||
if (bIsAdmin && !aIsAdmin) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((member) => (
|
||||
@@ -369,7 +647,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{member.user.avatar ? (
|
||||
<img src={member.user.avatar} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||
@@ -387,16 +665,16 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
||||
)}
|
||||
</p>
|
||||
{member.role === 'admin' && (
|
||||
{['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
||||
<Crown size={10} />
|
||||
{t('adminBadge')}
|
||||
{t('adminBadge') || 'Админ'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
||||
</div>
|
||||
{isAdmin && member.user.id !== user?.id && member.role !== 'admin' && (
|
||||
{isAdmin && member.user.id !== user?.id && !['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user.id)}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
||||
@@ -418,6 +696,92 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
onConfirm={confirmRemoveMember}
|
||||
onCancel={() => setRemoveTargetId(null)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
url: getMediaUrl(m.url),
|
||||
type: m.type
|
||||
})))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCropping && cropImage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
|
||||
>
|
||||
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
|
||||
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-80 bg-black">
|
||||
<Cropper
|
||||
image={cropImage}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-vortex-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={() => setIsCropping(false)}
|
||||
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCropSave}
|
||||
disabled={avatarUploading}
|
||||
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{avatarUploading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { extractWaveform } from '../lib/utils';
|
||||
import { extractWaveform, getMediaUrl } from '../lib/utils';
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
|
||||
@@ -434,11 +434,11 @@ function MessageBubble({
|
||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
|
||||
{message.storyMediaType === 'video' ? (
|
||||
<div className="w-full h-full relative">
|
||||
<video src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" />
|
||||
<video src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
|
||||
</div>
|
||||
) : message.storyMediaType === 'image' ? (
|
||||
<img src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" alt="" />
|
||||
<img src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-vortex-500/20"><FileText size={10} className="text-vortex-400" /></div>
|
||||
)}
|
||||
|
||||
@@ -43,9 +43,10 @@ type SideView = 'main' | 'profile' | 'settings' | 'about' | 'themes' | 'friends'
|
||||
interface SideMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
|
||||
const { user, updateUser, logout } = useAuthStore();
|
||||
const { clearStore } = useChatStore();
|
||||
const { chatTheme, setChatTheme } = useThemeStore();
|
||||
@@ -53,12 +54,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
|
||||
const [view, setView] = useState<SideView>('main');
|
||||
const [prevView, setPrevView] = useState<SideView>('main');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [birthday, setBirthday] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [themeIndex, setThemeIndex] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -191,7 +186,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
||||
setIsEditing(false);
|
||||
setFriendSearch('');
|
||||
setFriendSearchResults([]);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -234,63 +228,12 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setDisplayName(user.displayName || '');
|
||||
setBio(user.bio || '');
|
||||
setBirthday(user.birthday || '');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updated = await api.updateProfile({
|
||||
displayName: displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
birthday: birthday || undefined,
|
||||
});
|
||||
updateUser(updated);
|
||||
setIsEditing(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const updated = await api.uploadAvatar(file);
|
||||
updateUser(updated);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
await api.removeAvatar();
|
||||
updateUser({ avatar: null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = (user?.displayName || user?.username || '??')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
@@ -299,8 +242,9 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
.toUpperCase();
|
||||
|
||||
const menuItems = [
|
||||
{ icon: User, label: t('myProfile'), onClick: () => changeView('profile') },
|
||||
{ icon: User, label: t('myProfile'), onClick: () => { onClose(); onOpenProfile(); } },
|
||||
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
||||
|
||||
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
||||
{ divider: true },
|
||||
{ icon: Info, label: t('aboutApp'), subtitle: 'SelfHost Messenger v1.0', onClick: () => changeView('about') },
|
||||
@@ -327,7 +271,7 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
<div className="relative p-6 pb-5">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
{/* Avatar with glow ring */}
|
||||
<div className="relative group cursor-pointer" onClick={() => changeView('profile')}>
|
||||
<div className="relative group cursor-pointer" onClick={() => { onClose(); onOpenProfile(); }}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-60 blur group-hover:opacity-90 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||
<div className="relative">
|
||||
{user?.avatar ? (
|
||||
@@ -405,187 +349,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= PROFILE VIEW =======
|
||||
const renderProfile = () => (
|
||||
<motion.div key="profile" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
<button onClick={() => { changeView('main'); setIsEditing(false); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-lg font-bold tracking-tight text-white drop-shadow-sm">{t('myProfile')}</h3>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button onClick={() => setIsEditing(true)} className="relative z-10 p-2 rounded-full 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="relative z-10 p-2 rounded-full text-vortex-400 hover:text-vortex-300 hover:bg-vortex-500/10 transition-all border border-vortex-500/20">
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Avatar section */}
|
||||
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
|
||||
|
||||
<div className="relative group">
|
||||
{/* Spinning gradient glow ring */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-50 blur group-hover:opacity-75 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||
|
||||
<div className="relative">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-28 h-28 rounded-full object-cover ring-4 ring-surface bg-surface" />
|
||||
) : (
|
||||
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center text-white font-bold text-3xl ring-4 ring-surface relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
|
||||
<span className="relative z-10 text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload overlay */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
className="absolute inset-x-1 bottom-1 h-9 rounded-full bg-black/60 backdrop-blur-md border border-white/10 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-1.5 text-xs font-medium text-white transition-all transform translate-y-2 group-hover:translate-y-0"
|
||||
>
|
||||
{avatarUploading ? (
|
||||
<Loader2 size={14} className="text-vortex-400 animate-spin" />
|
||||
) : (
|
||||
<Camera size={14} className="text-vortex-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Remove avatar button */}
|
||||
{user?.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={avatarUploading}
|
||||
className="absolute h-7 px-2.5 -top-1 left-1/2 -translate-x-1/2 bg-red-500/80 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center gap-1 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"
|
||||
>
|
||||
<Trash2 size={10} className="text-white" />
|
||||
<span className="text-[10px] font-semibold text-white">{t('removePhoto')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
{isEditing ? (
|
||||
<div className="mt-5 w-full max-w-[260px] relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-vortex-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-vortex-400 placeholder-white/30"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="mt-4 text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
||||
{user?.displayName || user?.username}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Username badge */}
|
||||
<div className="flex items-center gap-1.5 mt-2 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-3.5 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
||||
<AtSign size={13} className="text-vortex-400" />
|
||||
<span className="text-sm font-semibold text-vortex-100">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="px-4 space-y-2.5 pb-6">
|
||||
{/* About */}
|
||||
<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">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-vortex-500/20 flex items-center justify-center border border-vortex-500/30">
|
||||
<Edit3 size={12} className="text-vortex-400" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-vortex-200/50 uppercase tracking-widest">{t('aboutMe')}</span>
|
||||
</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-vortex-500 transition-colors resize-none outline-none leading-relaxed"
|
||||
placeholder={t('tellAboutYourself')}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
||||
{user?.bio || <span className="text-white/30 italic">{t('notSpecified')}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Birthday */}
|
||||
<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">
|
||||
<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>
|
||||
<span className="text-xs font-semibold text-orange-200/50 uppercase tracking-widest">{t('birthday')}</span>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<DatePicker value={birthday} onChange={setBirthday} />
|
||||
) : (
|
||||
<p className="text-sm text-zinc-200 pl-1">
|
||||
{user?.birthday ? (
|
||||
new Date(user.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>
|
||||
|
||||
{/* Member since */}
|
||||
{user?.createdAt && (
|
||||
<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">
|
||||
<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>
|
||||
<span className="text-xs font-semibold text-emerald-200/50 uppercase tracking-widest">{t('onVortexSince')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 pl-1">
|
||||
{new Date(user.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{isEditing && (
|
||||
<div className="px-4 pb-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => { setIsEditing(false); setDisplayName(user?.displayName || ''); setBio(user?.bio || ''); setBirthday(user?.birthday || ''); }}
|
||||
className="flex-1 py-3 rounded-xl bg-black/20 hover:bg-black/40 border border-white/5 text-sm font-semibold text-zinc-300 hover:text-white transition-all backdrop-blur-md"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 hover:from-vortex-600 hover:to-purple-700 text-sm font-bold text-white transition-all shadow-[0_0_20px_rgba(168,85,247,0.4)] flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= SETTINGS VIEW =======
|
||||
const renderSettings = () => (
|
||||
<motion.div key="settings" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
@@ -988,7 +751,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
>
|
||||
<AnimatePresence mode="wait" custom={slideDir}>
|
||||
{view === 'main' && renderMain()}
|
||||
{view === 'profile' && renderProfile()}
|
||||
{view === 'settings' && renderSettings()}
|
||||
{view === 'themes' && renderThemes()}
|
||||
{view === 'friends' && renderFriends()}
|
||||
|
||||
@@ -220,6 +220,7 @@ export default function Sidebar() {
|
||||
<SideMenu
|
||||
isOpen={showSideMenu}
|
||||
onClose={() => setShowSideMenu(false)}
|
||||
onOpenProfile={() => setShowProfile(true)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSocket } from '../lib/socket';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import Avatar from './Avatar';
|
||||
import { StoryGroup } from '../lib/types';
|
||||
import { getMediaUrl } from '../lib/utils';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
@@ -64,20 +65,18 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
// Calculate isVideo before using it in effects
|
||||
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
|
||||
|
||||
// Pause when showing reactions or reply input
|
||||
// Pause when showing reactions or reply input or state changed
|
||||
useEffect(() => {
|
||||
if (showReactions || showReplyInput || showViewers) {
|
||||
setPaused(true);
|
||||
if (showReactions || showReplyInput || showViewers || paused) {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
} else {
|
||||
setPaused(false);
|
||||
if (videoRef.current && videoRef.current.paused && isVideo) {
|
||||
videoRef.current.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
}, [showReactions, showReplyInput, showViewers, isVideo]);
|
||||
}, [showReactions, showReplyInput, showViewers, paused, isVideo]);
|
||||
|
||||
// Handle video play/pause sync with paused state
|
||||
useEffect(() => {
|
||||
@@ -163,8 +162,11 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
}, [storyIndex, userIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || !currentStory) return;
|
||||
const step = (TICK / STORY_DURATION) * 100;
|
||||
if (paused || !currentStory || isVideo) return;
|
||||
|
||||
const duration = STORY_DURATION;
|
||||
const step = (TICK / duration) * 100;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
@@ -178,7 +180,22 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [storyIndex, userIndex, paused, goNext]);
|
||||
}, [storyIndex, userIndex, paused, goNext, isVideo, currentStory]);
|
||||
|
||||
// Handle video progress
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isVideo || paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (video.duration) {
|
||||
const p = (video.currentTime / video.duration) * 100;
|
||||
setProgress(p);
|
||||
}
|
||||
}, TICK);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isVideo, paused, storyIndex, userIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -325,7 +342,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
};
|
||||
|
||||
const avatarUrl = currentUser.user.avatar
|
||||
? `${API_URL}${currentUser.user.avatar}`
|
||||
? getMediaUrl(currentUser.user.avatar)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -345,7 +362,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
src={getMediaUrl(currentStory.mediaUrl)}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
@@ -356,7 +373,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<img
|
||||
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
src={getMediaUrl(currentStory.mediaUrl)}
|
||||
alt="story"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
@@ -435,10 +452,17 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex z-[5]">
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
|
||||
<div
|
||||
className="absolute inset-0 flex z-[5]"
|
||||
onMouseDown={() => setPaused(true)}
|
||||
onMouseUp={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
onMouseLeave={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
onTouchStart={() => setPaused(true)}
|
||||
onTouchEnd={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||
>
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goPrev(); }} />
|
||||
<div className="w-1/3 h-full" />
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goNext(); }} />
|
||||
</div>
|
||||
|
||||
{canGoPrev && (
|
||||
@@ -574,7 +598,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
{viewers.map((v) => (
|
||||
<div key={v.userId} className="flex items-center gap-3 py-1.5">
|
||||
<Avatar
|
||||
src={v.avatar ? `${API_URL}${v.avatar}` : null}
|
||||
src={v.avatar ? getMediaUrl(v.avatar) : null}
|
||||
name={v.displayName || v.username}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
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 } from 'lucide-react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
import StoryViewer from './StoryViewer';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { useStoryStore } from '../stores/useStoryStore';
|
||||
import { getMediaUrl } from '../lib/utils';
|
||||
import { getCroppedImg } from '../lib/imageCrop';
|
||||
import DatePicker from './DatePicker';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
@@ -17,39 +23,51 @@ interface UserProfileProps {
|
||||
isSelf?: boolean;
|
||||
}
|
||||
|
||||
type MediaTab = 'stories' | 'media' | 'files' | 'links';
|
||||
type MediaTab = 'publications' | 'media' | 'files' | 'links';
|
||||
|
||||
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
|
||||
const { user: authUser } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const [profile, setProfile] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<MediaTab>('media');
|
||||
const [activeTab, setActiveTab] = useState<MediaTab>('publications');
|
||||
|
||||
// Shared media state
|
||||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [userStories, setUserStories] = useState<StoryGroup | null>(null);
|
||||
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 [isEditingAvatar, setIsEditingAvatar] = useState(false);
|
||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
|
||||
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: m.url.startsWith('http') ? m.url : `${import.meta.env.VITE_API_URL}${m.url}`,
|
||||
url: getMediaUrl(m.url),
|
||||
messageId: msg.id
|
||||
})));
|
||||
|
||||
@@ -58,32 +76,22 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
if (!isSelf) {
|
||||
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
||||
}
|
||||
}, [userId]);
|
||||
}, [userId, isSelf]);
|
||||
|
||||
// Load shared media/files/links when tab changes
|
||||
const loadTabData = useCallback(async (tab: MediaTab) => {
|
||||
if (tab === 'stories') {
|
||||
if (loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await api.getUserStories(userId);
|
||||
setUserStories(data);
|
||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||
} catch (e) {
|
||||
console.error('Failed to load user stories', e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chatId || loadedTabs.has(tab)) return;
|
||||
if (loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await api.getSharedMedia(chatId, tab);
|
||||
if (tab === 'media') setSharedMedia(data);
|
||||
else if (tab === 'files') setSharedFiles(data);
|
||||
else setSharedLinks(data);
|
||||
if (tab === 'publications') {
|
||||
const data = await api.getUserStories(userId);
|
||||
setUserStories(data.stories || []);
|
||||
} else if (chatId) { // Only load media/files/links if chatId is available
|
||||
const data = await api.getSharedMedia(chatId, tab);
|
||||
if (tab === 'media') setSharedMedia(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);
|
||||
@@ -101,9 +109,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
setIsLoading(true);
|
||||
if (isSelf && authUser) {
|
||||
setProfile(authUser);
|
||||
setDisplayName(authUser.displayName || '');
|
||||
setBio(authUser.bio || '');
|
||||
setBirthday(authUser.birthday || '');
|
||||
} else {
|
||||
const data = await api.getUser(userId);
|
||||
setProfile(data);
|
||||
if (isSelf) {
|
||||
setDisplayName(data.displayName || '');
|
||||
setBio(data.bio || '');
|
||||
setBirthday(data.birthday || '');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -112,6 +128,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const dateToSave = birthday ? new Date(birthday).toISOString() : undefined;
|
||||
const updated = await api.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);
|
||||
@@ -172,25 +207,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
};
|
||||
|
||||
const handleCropSave = async () => {
|
||||
if (!cropFile || !cropImage) return;
|
||||
if (!cropImage || !croppedAreaPixels) return;
|
||||
|
||||
try {
|
||||
setTabLoading(true);
|
||||
// For now, we'll just send the file and the crop data as separate fields
|
||||
// In a real app, we might crop on the client via canvas
|
||||
const cropData = {
|
||||
x: Math.round(cropPosition.x),
|
||||
y: Math.round(cropPosition.y),
|
||||
width: 400, // Fixed size for simplicity
|
||||
height: 400
|
||||
};
|
||||
setTabLoading(true); // Reusing tabLoading for avatar upload
|
||||
|
||||
const updatedUser = await api.cropAvatar(cropFile, cropData);
|
||||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||
if (!croppedFile) throw new Error("Could not crop image");
|
||||
|
||||
const updatedUser = await api.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 {
|
||||
@@ -198,6 +231,20 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
try {
|
||||
setTabLoading(true);
|
||||
await api.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])
|
||||
@@ -206,7 +253,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
.toUpperCase();
|
||||
|
||||
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'stories', label: (t('storiesTab') || 'Stories') as string, icon: ImageIcon },
|
||||
{ key: 'publications', label: (t('publicationsTab') || 'Публикации') as string, icon: Play },
|
||||
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
|
||||
{ key: 'files', label: t('filesTab') as string, icon: FileText },
|
||||
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
|
||||
@@ -222,24 +269,44 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
||||
initial={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
|
||||
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[500px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
|
||||
exit={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
|
||||
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-white/5 bg-white/5 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
||||
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
|
||||
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10 flex-1">
|
||||
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
|
||||
</h2>
|
||||
<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 relative z-10"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<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-vortex-500/20 text-vortex-400 hover:text-vortex-300 hover:bg-vortex-500/30 transition-all border border-vortex-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 ? (
|
||||
@@ -259,7 +326,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<div className="relative">
|
||||
{profile.avatar ? (
|
||||
<img
|
||||
src={profile.avatar}
|
||||
src={getMediaUrl(profile.avatar)}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover ring-4 ring-surface bg-surface"
|
||||
/>
|
||||
@@ -279,23 +346,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
)}
|
||||
|
||||
{isSelf && (
|
||||
<label className="absolute bottom-0 right-0 w-10 h-10 bg-accent hover:bg-accent-light text-white rounded-full border-4 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110 z-20">
|
||||
<Edit3 size={18} />
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,video/*"
|
||||
onChange={handleAvatarSelect}
|
||||
/>
|
||||
</label>
|
||||
<div className="absolute top-0 right-0 p-1.5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<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 h-9 px-3 -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 gap-1.5 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"
|
||||
>
|
||||
<Trash2 size={12} className="text-white" />
|
||||
<span className="text-xs font-semibold text-white">{t('removePhoto')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Имя */}
|
||||
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
||||
{profile.displayName || profile.username}
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<div className="mt-5 w-full max-w-[260px] relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-vortex-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-vortex-400 placeholder-white/30 truncate"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
||||
{profile.displayName || profile.username}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Username (неизменяемый) */}
|
||||
<div className="flex items-center gap-1.5 mt-2.5 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-4 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
||||
@@ -382,15 +474,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
{t('aboutMe')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
||||
{profile.bio || (
|
||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
||||
)}
|
||||
</p>
|
||||
{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-vortex-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 && (
|
||||
{(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">
|
||||
@@ -400,17 +502,21 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
{t('birthday')}
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -436,72 +542,74 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex px-2 pt-2 gap-1 overflow-x-auto no-scrollbar">
|
||||
{tabs.map((tab) => (
|
||||
<div className="flex border-b border-white/5 h-14">
|
||||
{[
|
||||
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play },
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-1 text-xs font-bold transition-all rounded-t-xl min-w-[100px] ${activeTab === tab.key
|
||||
? 'bg-white/10 text-white shadow-[inset_0_2px_10px_rgba(255,255,255,0.05)] border-t border-x border-white/10'
|
||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
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-vortex-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={14} className={activeTab === tab.key ? 'text-vortex-400' : 'opacity-70'} />
|
||||
{tab.label}
|
||||
<tab.icon size={16} />
|
||||
<span className="truncate w-full px-1">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-[160px] bg-white/[0.02] border-t border-white/5 relative">
|
||||
{/* Subtle top glow for active tab content */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-vortex-500/50 to-transparent" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-zinc-500" />
|
||||
<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 === 'stories' ? (
|
||||
userStories && userStories.stories.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{userStories.stories.map((story, idx) => (
|
||||
) : activeTab === 'publications' ? (
|
||||
userStories.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{userStories.map((s, idx) => (
|
||||
<div
|
||||
key={story.id}
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
const group: StoryGroup = {
|
||||
user: profile!,
|
||||
stories: userStories.stories,
|
||||
hasUnviewed: false
|
||||
};
|
||||
openViewer(0, idx, [group]);
|
||||
setInitialStoryIdx(idx);
|
||||
setStoryViewerOpen(true);
|
||||
}}
|
||||
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden group border border-white/5 rounded-md cursor-pointer"
|
||||
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden cursor-pointer group rounded-sm"
|
||||
>
|
||||
{story.type === 'video' ? (
|
||||
{s.type === 'video' ? (
|
||||
<div className="w-full h-full relative">
|
||||
{story.mediaUrl && <video src={story.mediaUrl.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`} className="w-full h-full object-cover opacity-60" />}
|
||||
{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={20} className="text-white fill-white" />
|
||||
<Play size={24} className="text-white fill-white opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
) : story.type === 'image' ? (
|
||||
) : s.type === 'image' && s.mediaUrl ? (
|
||||
<img
|
||||
src={story.mediaUrl?.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`}
|
||||
key={s.id}
|
||||
src={getMediaUrl(s.mediaUrl)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center p-2 text-center overflow-hidden" style={{ background: story.bgColor || 'var(--vortex-500)' }}>
|
||||
<p className="text-[10px] text-white line-clamp-4 font-bold">{story.content}</p>
|
||||
<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 top-1 right-1 bg-black/50 backdrop-blur-sm px-1 rounded flex items-center gap-0.5">
|
||||
<Clock size={8} className="text-zinc-300" />
|
||||
<span className="text-[8px] text-zinc-300">{new Date(story.createdAt).toLocaleDateString()}</span>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{(t('noStories') || 'No stories yet') as string}</p>
|
||||
<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' ? (
|
||||
@@ -514,19 +622,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<img
|
||||
src={m.thumbnail ? (m.thumbnail.startsWith('http') ? m.thumbnail : `${import.meta.env.VITE_API_URL}${m.thumbnail}`) : m.url}
|
||||
alt=""
|
||||
<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)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
>
|
||||
{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={m.url}
|
||||
src={getMediaUrl(m.url)}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
@@ -555,7 +669,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={m.url}
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
@@ -640,13 +754,39 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={allMedia.map((m) => ({ url: m.url, type: m.type }))}
|
||||
images={allMedia.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: userStories,
|
||||
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 && (
|
||||
@@ -664,37 +804,46 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex flex-col items-center">
|
||||
<div className="relative w-64 h-64 rounded-full overflow-hidden border-4 border-accent/30 bg-black group">
|
||||
{cropFile?.type.startsWith('video/') ? (
|
||||
<video src={cropImage} className="w-full h-full object-cover opacity-80" autoPlay muted loop />
|
||||
) : (
|
||||
<img src={cropImage} className="w-full h-full object-cover opacity-80" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-48 h-48 rounded-full border-2 border-dashed border-white/50 animate-pulse pointer-events-none" />
|
||||
</div>
|
||||
{/* Fake cropping area overlay */}
|
||||
<div className="absolute inset-0 bg-black/40 pointer-events-none" style={{
|
||||
clipPath: 'circle(48% at 50% 50%)'
|
||||
}} />
|
||||
<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-vortex-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-zinc-400 text-center px-4 leading-relaxed">
|
||||
{t('photoVideo')}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 mt-8 w-full">
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={() => setIsCropping(false)}
|
||||
className="flex-1 py-3.5 rounded-2xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||
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.5 rounded-2xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||
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')}
|
||||
@@ -705,6 +854,16 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={allMedia.map(m => ({ url: m.url, type: m.type }))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// \u0413\u0440\u0443\u043f\u043f\u044b
|
||||
async updateGroup(chatId: string, data: { name?: string }) {
|
||||
async updateGroup(chatId: string, data: { name?: string; description?: string }) {
|
||||
return this.request<Chat>(`/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
@@ -203,6 +203,26 @@ class ApiClient {
|
||||
return response.json() as Promise<Chat>;
|
||||
}
|
||||
|
||||
async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('x', cropData.x.toString());
|
||||
formData.append('y', cropData.y.toString());
|
||||
formData.append('width', cropData.width.toString());
|
||||
formData.append('height', cropData.height.toString());
|
||||
|
||||
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar/crop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка кропа аватара');
|
||||
return response.json() as Promise<Chat>;
|
||||
}
|
||||
|
||||
async removeGroupAvatar(chatId: string) {
|
||||
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ const translations = {
|
||||
removeMember: 'Удалить из группы',
|
||||
leaveGroup: 'Покинуть группу',
|
||||
adminBadge: 'Админ',
|
||||
groupDescription: 'Описание',
|
||||
noDescription: 'Нет описания',
|
||||
memberBadge: 'Участник',
|
||||
screenShare: 'Демонстрация экрана',
|
||||
stopScreenShare: 'Остановить демонстрацию',
|
||||
@@ -179,7 +181,8 @@ const translations = {
|
||||
sharedFiles: 'Общие файлы будут здесь',
|
||||
sharedLinks: 'Общие ссылки будут здесь',
|
||||
profileNotFound: 'Профиль не найден',
|
||||
storiesTab: 'Публикации',
|
||||
storiesTab: 'Истории',
|
||||
publicationsTab: 'Публикации',
|
||||
noStories: 'Публикаций пока нет',
|
||||
goToMessage: 'Перейти к сообщению',
|
||||
story: 'История',
|
||||
@@ -405,6 +408,8 @@ const translations = {
|
||||
removeMember: 'Remove from group',
|
||||
leaveGroup: 'Leave group',
|
||||
adminBadge: 'Admin',
|
||||
groupDescription: 'Description',
|
||||
noDescription: 'No description',
|
||||
memberBadge: 'Member',
|
||||
screenShare: 'Screen share',
|
||||
stopScreenShare: 'Stop sharing',
|
||||
@@ -442,6 +447,7 @@ const translations = {
|
||||
sharedLinks: 'Shared links will appear here',
|
||||
profileNotFound: 'Profile not found',
|
||||
storiesTab: 'Stories',
|
||||
publicationsTab: 'Publications',
|
||||
noStories: 'No stories yet',
|
||||
goToMessage: 'Go to message',
|
||||
story: 'Story',
|
||||
|
||||
49
apps/web/src/lib/imageCrop.ts
Normal file
49
apps/web/src/lib/imageCrop.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const getCroppedImg = async (
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number }
|
||||
): Promise<File | null> => {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set sizing
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// Draw cropped image
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
resolve(file);
|
||||
}, 'image/jpeg', 0.95);
|
||||
});
|
||||
};
|
||||
|
||||
const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', (error) => reject(error));
|
||||
image.setAttribute('crossOrigin', 'anonymous');
|
||||
image.src = url;
|
||||
});
|
||||
@@ -94,6 +94,7 @@ export interface Chat {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
members: ChatMember[];
|
||||
|
||||
@@ -137,3 +137,13 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||
|
||||
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
|
||||
// will resolve against the current origin (port).
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user