Новый дизайн, удаление

This commit is contained in:
Халимов Рустам
2026-03-12 22:54:15 +03:00
parent 1ec6b438d2
commit 9daafae9c5
158 changed files with 1511 additions and 254 deletions

View File

@@ -17,6 +17,7 @@ import {
Forward,
Bookmark,
ArrowLeft,
MessageSquare,
} from 'lucide-react';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
@@ -317,10 +318,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (!activeChat || !chat) {
return (
<div className="flex-1 flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
<div className="flex-1 h-full flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
{/* Slowly pulsing purple background as requested */}
<div className="absolute inset-0 pointer-events-none overflow-hidden transition-opacity duration-[10000ms]">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-vortex-600/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-accent/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] max-w-[500px] max-h-[500px] bg-purple-600/15 rounded-full blur-[100px] animate-[pulse_12s_ease-in-out_infinite_reverse]" />
</div>
@@ -331,18 +332,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-vortex-500/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-accent/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
>
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
<img src="/logo.png" alt="Vortex" className="w-16 h-16 rounded-2xl object-cover shadow-2xl transform hover:scale-105 transition-transform" />
<MessageSquare className="w-16 h-16 text-accent shadow-2xl transform hover:scale-105 transition-transform" />
</motion.div>
<motion.h2
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-vortex-400 via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
>
Vortex Messenger
SelfHost Messenger
</motion.h2>
<motion.p
initial={{ y: 20, opacity: 0 }}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { getSocket } from '../lib/socket';
import { api } from '../lib/api';
import { useLang } from '../lib/i18n';
@@ -10,6 +12,7 @@ interface ParticipantInfo {
username: string;
displayName?: string;
avatar?: string | null;
isSharingScreen?: boolean;
}
interface PeerState {
@@ -65,6 +68,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
const [activeMicId, setActiveMicId] = useState<string>('');
const [showMicMenu, setShowMicMenu] = useState(false);
const [sharingUserId, setSharingUserId] = useState<string | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
@@ -289,6 +293,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
}
}
setIsScreenSharing(false);
const socket = getSocket();
socket?.emit('screen_share_stopped', chatId);
} else {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
@@ -328,6 +334,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
};
setIsScreenSharing(true);
const socket = getSocket();
socket?.emit('screen_share_started', chatId);
} catch { console.error('Screen share failed'); }
}
}, [isScreenSharing, chatId]);
@@ -521,6 +529,9 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
const newMap = new Map<string, ParticipantInfo>();
for (const p of data.participants) {
newMap.set(p.id, p);
if (p.isSharingScreen && p.id !== useAuthStore.getState().user?.id) {
setSharingUserId(p.id);
}
// Create peer connection to each existing participant (we are the initiator)
if (!peersRef.current.has(p.id)) {
await createPeerConnection(p.id, true);
@@ -605,6 +616,38 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
}
};
const onScreenShareStarted = (data: { userId: string }) => {
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe) {
setSharingUserId(data.userId);
}
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isSharingScreen: true });
return next;
});
};
const onScreenShareStopped = (data: { userId: string }) => {
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe && sharingUserId === data.userId) {
setSharingUserId(null);
} else if (isMe) {
// Just in case
}
// If the person who stopped was the sharing user, clear it
setSharingUserId(current => current === data.userId ? null : current);
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isSharingScreen: false });
return next;
});
};
socket.on('group_call_participants', onParticipants);
socket.on('group_call_user_joined', onUserJoined);
socket.on('group_call_user_left', onUserLeft);
@@ -613,6 +656,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
socket.on('group_ice_candidate', onIceCandidate);
socket.on('group_call_renegotiate', onRenegotiate);
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
socket.on('screen_share_started', onScreenShareStarted);
socket.on('screen_share_stopped', onScreenShareStopped);
return () => {
socket.off('group_call_participants', onParticipants);
@@ -623,6 +668,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
socket.off('group_ice_candidate', onIceCandidate);
socket.off('group_call_renegotiate', onRenegotiate);
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
socket.off('screen_share_started', onScreenShareStarted);
socket.off('screen_share_stopped', onScreenShareStopped);
};
}, [isOpen, chatId, createPeerConnection]);
@@ -856,6 +903,16 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
</div>
</div>
{/* Screen share indicator */}
{sharingUserId && (
<div className="absolute top-6 left-6 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 backdrop-blur-md">
<Monitor size={14} className="text-blue-400 animate-pulse" />
<span className="text-xs font-medium text-blue-100">
{participants.get(sharingUserId)?.displayName || 'Кто-то'} транслирует экран
</span>
</div>
)}
{/* Controls */}
<div className="px-8 pb-8 pt-4 flex items-center justify-center gap-4 flex-wrap">
{/* Mic with dropdown */}
@@ -882,12 +939,16 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
</button>
<button
onClick={toggleScreenShare}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</button>
onClick={toggleScreenShare}
disabled={!!sharingUserId && !isScreenSharing}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
: 'bg-white/10 text-white hover:bg-white/15'
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
>
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
</button>
<button
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"

View File

@@ -55,6 +55,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
try {
const chat = await api.createPersonalChat(selectedUser.id);
addChat(chat);
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
@@ -75,14 +76,15 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
if (!groupName.trim() || selectedUsers.length === 0) return;
setIsCreating(true);
try {
const chat = await api.createGroupChat(
groupName.trim(),
selectedUsers.map((u) => u.id)
);
addChat(chat);
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
const chat = await api.createGroupChat(
groupName.trim(),
selectedUsers.map((u) => u.id)
);
addChat(chat);
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
} catch (e) {
console.error(e);
} finally {

View File

@@ -303,7 +303,7 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
{ 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: 'Vortex Messenger v1.0', onClick: () => changeView('about') },
{ icon: Info, label: t('aboutApp'), subtitle: 'SelfHost Messenger v1.0', onClick: () => changeView('about') },
];
// Slide direction for animations
@@ -664,13 +664,13 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
</div>
<div className="px-5 py-3">
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('about')}</h4>
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
<Info size={18} className="text-zinc-400" />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-200">Vortex Messenger</p>
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
</div>
</div>
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
<Info size={18} className="text-zinc-400" />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-200">SelfHost Messenger</p>
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
</div>
</div>
</div>
</div>
</motion.div>

View File

@@ -86,8 +86,10 @@ export default function Sidebar() {
<Menu size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<img src="/logo.png" alt="Vortex" className="w-8 h-8 rounded-lg object-cover" />
<h1 className="text-lg font-bold gradient-text truncate">Vortex</h1>
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white">
<MessageSquare size={18} />
</div>
<h1 className="text-lg font-bold gradient-text truncate">SelfHost</h1>
</div>
<button
onClick={() => setShowNewChat(true)}
@@ -128,8 +130,8 @@ export default function Sidebar() {
onClick={() => setShowCreateStory(true)}
className="flex flex-col items-center gap-1 flex-shrink-0 group"
>
<div className="w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-vortex-400 transition-colors">
<Plus size={18} className="text-zinc-400 group-hover:text-vortex-400 transition-colors" />
<div className="w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-accent transition-colors">
<Plus size={18} className="text-zinc-400 group-hover:text-accent transition-colors" />
</div>
<span className="text-[10px] text-zinc-500 truncate w-12 sm:w-14 text-center">{t('newStory')}</span>
</button>
@@ -145,7 +147,7 @@ export default function Sidebar() {
>
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
group.hasUnviewed
? 'bg-gradient-to-tr from-vortex-400 via-purple-500 to-pink-500 shadow-lg shadow-vortex-500/25'
? 'bg-gradient-to-tr from-accent via-purple-500 to-pink-500 shadow-lg shadow-accent/25'
: isMine
? 'bg-gradient-to-tr from-zinc-500 to-zinc-600'
: 'bg-zinc-700'

View File

@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import { getSocket } from '../lib/socket';
import { useLang } from '../lib/i18n';
import { getInitials, generateAvatarColor } from '../lib/utils';
import Avatar from './Avatar';
@@ -124,16 +125,49 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
};
}, [storyIndex, userIndex, paused, goNext]);
// Keyboard
// Keyboard and Socket
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowRight') goNext();
if (e.key === 'ArrowLeft') goPrev();
};
const socket = getSocket();
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number }) => {
if (!currentStory || data.storyId !== currentStory.id) return;
setViewOverrides(prev => ({
...prev,
[data.storyId]: {
viewCount: data.viewCount,
viewed: prev[data.storyId]?.viewed || false
}
}));
// Update viewers list if visible
if (showViewers) {
setViewers(prev => {
if (prev.some(v => v.userId === data.userId)) return prev;
return [...prev, {
userId: data.userId,
username: data.username,
displayName: data.displayName,
avatar: data.avatar,
viewedAt: data.viewedAt
}].sort((a, b) => new Date(b.viewedAt).getTime() - new Date(a.viewedAt).getTime());
});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [goNext, goPrev, onClose]);
socket?.on('story_viewed', handleStoryViewed);
return () => {
window.removeEventListener('keydown', onKey);
socket?.off('story_viewed', handleStoryViewed);
};
}, [goNext, goPrev, onClose, currentStory?.id, showViewers]);
const handleDelete = async () => {
if (!currentStory) return;