Доработки профиля

This commit is contained in:
Халимов Рустам
2026-03-14 01:59:06 +03:00
parent 010b96d362
commit 15344f8636
69 changed files with 1625 additions and 561 deletions

View File

@@ -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]);

View File

@@ -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"
/>

View File

@@ -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>
);

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
)}

View File

@@ -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()}

View File

@@ -220,6 +220,7 @@ export default function Sidebar() {
<SideMenu
isOpen={showSideMenu}
onClose={() => setShowSideMenu(false)}
onOpenProfile={() => setShowProfile(true)}
/>
<AnimatePresence>
{viewerIndex !== null && storyGroups.length > 0 && (

View File

@@ -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"

View File

@@ -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>
</>
);
}

View File

@@ -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' });
}

View File

@@ -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',

View 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;
});

View File

@@ -94,6 +94,7 @@ export interface Chat {
id: string;
type: string;
name: string | null;
description?: string | null;
avatar: string | null;
createdAt: string;
members: ChatMember[];

View File

@@ -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}`;
}