mirror of
https://github.com/lorsanstand/Aether.git
synced 2026-06-19 12:05:16 +03:00
1376 lines
59 KiB
TypeScript
1376 lines
59 KiB
TypeScript
import { useAuthStore } from '../store/authStore';
|
||
import { useChatStore } from '../store/chatStore';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { MessageSquarePlus, Settings, User, Send, ArrowLeft, X, Pencil, Check, ArrowDown, Trash2 } from 'lucide-react';
|
||
import miniLogo from '../assets/mini-logo.png';
|
||
import VerificationBanner from '../components/common/VerificationBanner';
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import chatService, { type Chat, type Message } from '../services/chatService';
|
||
import { userService, type User as UserType } from '../services/userService';
|
||
|
||
export default function ChatPage() {
|
||
const user = useAuthStore((state) => state.user);
|
||
const navigate = useNavigate();
|
||
const { chatId } = useParams<{ chatId: string }>();
|
||
|
||
// Chat store with cache
|
||
const cachedChats = useChatStore((state) => state.chats);
|
||
const setChatsCache = useChatStore((state) => state.setChats);
|
||
const updateChatCache = useChatStore((state) => state.updateChat);
|
||
|
||
const [chats, setChats] = useState<Chat[]>(cachedChats); // Initialize with cached data
|
||
const [loading, setLoading] = useState(cachedChats.length === 0); // Don't show loading if we have cache
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Selected chat state
|
||
const [selectedChat, setSelectedChat] = useState<Chat | null>(null);
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||
const [messagesError, setMessagesError] = useState<string | null>(null);
|
||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
|
||
// User profile modal state
|
||
const [viewingUser, setViewingUser] = useState<UserType | null>(null);
|
||
const [_userProfileLoading, setUserProfileLoading] = useState(false);
|
||
|
||
// Message input state
|
||
const [messageText, setMessageText] = useState('');
|
||
const [sendingMessage, setSendingMessage] = useState(false);
|
||
|
||
// Message editing state
|
||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||
const [editingMessageText, setEditingMessageText] = useState('');
|
||
|
||
// Unread messages counter
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||
const isAtBottomRef = useRef(true); // Track if user is at bottom
|
||
|
||
// New chat / user search state
|
||
const [showNewChatModal, setShowNewChatModal] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<UserType[]>([]);
|
||
const [searchLoading, setSearchLoading] = useState(false);
|
||
const searchTimeoutRef = useRef<number | null>(null);
|
||
|
||
// Ref for auto-scroll to bottom
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// WebSocket ref
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
|
||
// Input ref for auto-focus
|
||
const messageInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
const loadChats = async () => {
|
||
try {
|
||
if (cachedChats.length === 0) {
|
||
setLoading(true);
|
||
}
|
||
setError(null);
|
||
|
||
const data = await chatService.getChats(0, 50);
|
||
setChats(data);
|
||
setChatsCache(data);
|
||
|
||
// If chatId in URL, select that chat (messages will load in next useEffect)
|
||
if (chatId && data.length > 0) {
|
||
const chat = data.find(c => c.chat_id === chatId);
|
||
if (chat) {
|
||
setSelectedChat(chat);
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.detail || 'Ошибка загрузки чатов');
|
||
console.error('Failed to load chats:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadChats();
|
||
}, [chatId, setChatsCache]);
|
||
|
||
useEffect(() => {
|
||
if (selectedChat) {
|
||
// Reset messages and load from beginning
|
||
setMessages([]);
|
||
setHasMoreMessages(true);
|
||
setUnreadCount(0);
|
||
setShowScrollButton(false);
|
||
isAtBottomRef.current = true;
|
||
loadMessages(selectedChat.chat_id, true);
|
||
// Auto-focus input when chat is selected
|
||
setTimeout(() => {
|
||
messageInputRef.current?.focus();
|
||
}, 100);
|
||
}
|
||
}, [selectedChat?.chat_id]);
|
||
|
||
// WebSocket connection
|
||
useEffect(() => {
|
||
const connectWebSocket = () => {
|
||
// Get token from cookies for WebSocket auth
|
||
const wsUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||
const wsProtocol = wsUrl.startsWith('https') ? 'wss' : 'ws';
|
||
const wsBase = wsUrl.replace('http://', '').replace('https://', '');
|
||
|
||
const ws = new WebSocket(`${wsProtocol}://${wsBase}/chats/ws`);
|
||
|
||
ws.onopen = () => {
|
||
console.log('WebSocket connected');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
console.log('Received WebSocket data:', data);
|
||
|
||
// Handle message deletion
|
||
if (data.type === 'del' && data.message_id) {
|
||
setMessages(prev => prev.filter(m => m.id !== data.message_id));
|
||
return;
|
||
}
|
||
|
||
// Handle regular message (add/update)
|
||
const message: Message = data;
|
||
|
||
// Add or update message in current chat if it belongs to it
|
||
if (selectedChat && message.chat_id === selectedChat.chat_id) {
|
||
const isNewMessage = !messages.some(m => m.id === message.id);
|
||
|
||
setMessages(prev => {
|
||
// Check if message already exists (update it if edited)
|
||
const existingIndex = prev.findIndex(m => m.id === message.id);
|
||
if (existingIndex !== -1) {
|
||
// Update existing message
|
||
const updated = [...prev];
|
||
updated[existingIndex] = message;
|
||
return updated;
|
||
}
|
||
// Add new message
|
||
return [...prev, message];
|
||
});
|
||
|
||
// Handle scroll and unread counter for new messages
|
||
if (isNewMessage) {
|
||
// Check if this is our own message
|
||
const isOwnMessage = message.sender_id === user?.id;
|
||
|
||
// Use ref to check current position synchronously
|
||
setTimeout(() => {
|
||
if (isOwnMessage || isAtBottomRef.current) {
|
||
// Auto-scroll if it's our message or if at bottom
|
||
scrollToBottom(true);
|
||
} else {
|
||
// Increment unread counter if not at bottom and not our message
|
||
setUnreadCount(prev => prev + 1);
|
||
setShowScrollButton(true);
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// Update chat list with new last message
|
||
setChats(prevChats =>
|
||
prevChats.map(chat =>
|
||
chat.chat_id === message.chat_id
|
||
? { ...chat, last_message: message.content }
|
||
: chat
|
||
)
|
||
);
|
||
|
||
// Update cache
|
||
updateChatCache(message.chat_id, { last_message: message.content });
|
||
} catch (error) {
|
||
console.error('Error parsing WebSocket message:', error);
|
||
}
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
console.log('WebSocket disconnected');
|
||
// Attempt to reconnect after 3 seconds
|
||
setTimeout(() => {
|
||
if (wsRef.current === ws) {
|
||
connectWebSocket();
|
||
}
|
||
}, 3000);
|
||
};
|
||
|
||
wsRef.current = ws;
|
||
};
|
||
|
||
connectWebSocket();
|
||
|
||
// Cleanup on unmount
|
||
return () => {
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
wsRef.current = null;
|
||
}
|
||
};
|
||
}, [selectedChat, updateChatCache]);
|
||
|
||
// Handle Escape key to close chat
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
if (viewingUser) {
|
||
setViewingUser(null);
|
||
} else if (selectedChat) {
|
||
handleBackToChats();
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [selectedChat, viewingUser]);
|
||
|
||
const loadMessages = async (chatId: string, isInitial: boolean = false) => {
|
||
try {
|
||
if (isInitial) {
|
||
setMessagesLoading(true);
|
||
} else {
|
||
setLoadingMore(true);
|
||
}
|
||
setMessagesError(null);
|
||
|
||
const offset = isInitial ? 0 : messages.length;
|
||
|
||
// Save scroll position before loading more
|
||
const scrollContainer = messagesContainerRef.current;
|
||
const previousScrollHeight = scrollContainer?.scrollHeight || 0;
|
||
|
||
const data = await chatService.getChatMessages(chatId, offset, 50);
|
||
|
||
if (data.length < 50) {
|
||
setHasMoreMessages(false);
|
||
}
|
||
|
||
// Backend returns messages in DESC order (newest first), reverse to show oldest first
|
||
const sortedData = [...data].reverse();
|
||
|
||
if (isInitial) {
|
||
setMessages(sortedData);
|
||
// Scroll to bottom on initial load
|
||
setTimeout(() => {
|
||
scrollToBottom(false);
|
||
checkScrollPosition();
|
||
}, 100);
|
||
} else {
|
||
// Prepend old messages to the beginning
|
||
setMessages(prev => [...sortedData, ...prev]);
|
||
|
||
// Restore scroll position after render
|
||
setTimeout(() => {
|
||
if (scrollContainer) {
|
||
const newScrollHeight = scrollContainer.scrollHeight;
|
||
scrollContainer.scrollTop = newScrollHeight - previousScrollHeight;
|
||
}
|
||
}, 0);
|
||
}
|
||
} catch (err: any) {
|
||
setMessagesError(err.response?.data?.detail || 'Ошибка загрузки сообщений');
|
||
console.error('Failed to load messages:', err);
|
||
} finally {
|
||
setMessagesLoading(false);
|
||
setLoadingMore(false);
|
||
}
|
||
};
|
||
|
||
const checkScrollPosition = () => {
|
||
const element = messagesContainerRef.current;
|
||
if (!element) return;
|
||
|
||
const threshold = 150; // pixels from bottom
|
||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||
const atBottom = distanceFromBottom < threshold;
|
||
|
||
isAtBottomRef.current = atBottom;
|
||
setShowScrollButton(!atBottom);
|
||
|
||
if (atBottom) {
|
||
setUnreadCount(0);
|
||
}
|
||
};
|
||
|
||
const scrollToBottom = (smooth: boolean = true) => {
|
||
if (messagesEndRef.current) {
|
||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||
setUnreadCount(0);
|
||
setShowScrollButton(false);
|
||
isAtBottomRef.current = true;
|
||
}
|
||
};
|
||
|
||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||
const element = e.currentTarget;
|
||
|
||
// Update scroll position tracking
|
||
checkScrollPosition();
|
||
|
||
// If scrolled to top and has more messages
|
||
if (element.scrollTop === 0 && hasMoreMessages && !loadingMore && selectedChat) {
|
||
loadMessages(selectedChat.chat_id, false);
|
||
}
|
||
};
|
||
|
||
const handleChatClick = (chat: Chat) => {
|
||
setSelectedChat(chat);
|
||
navigate(`/chat/${chat.chat_id}`);
|
||
};
|
||
|
||
const handleBackToChats = () => {
|
||
setSelectedChat(null);
|
||
setMessages([]);
|
||
navigate('/chat');
|
||
};
|
||
|
||
const handleViewUserProfile = async (userId: number) => {
|
||
try {
|
||
setUserProfileLoading(true);
|
||
const userData = await userService.getUserById(userId);
|
||
setViewingUser(userData);
|
||
} catch (err: any) {
|
||
console.error('Failed to load user profile:', err);
|
||
} finally {
|
||
setUserProfileLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSendMessage = async () => {
|
||
if (!messageText.trim() || sendingMessage) return;
|
||
|
||
try {
|
||
setSendingMessage(true);
|
||
|
||
// Send message with chat_id (если чат существует) или recipient_id (если новый чат)
|
||
await chatService.sendMessage({
|
||
content: messageText.trim(),
|
||
chat_id: selectedChat?.chat_id,
|
||
recipient_id: selectedChat?.user_id,
|
||
});
|
||
|
||
// Don't add message locally - it will come via WebSocket
|
||
// This prevents duplication
|
||
|
||
// Clear input
|
||
setMessageText('');
|
||
|
||
// Update chat list with new last message
|
||
const updatedChats = chats.map(chat =>
|
||
chat.chat_id === selectedChat?.chat_id
|
||
? { ...chat, last_message: messageText.trim() }
|
||
: chat
|
||
);
|
||
setChats(updatedChats);
|
||
setChatsCache(updatedChats); // Update cache
|
||
|
||
// Update cache for specific chat
|
||
if (selectedChat?.chat_id) {
|
||
updateChatCache(selectedChat.chat_id, { last_message: messageText.trim() });
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to send message:', err);
|
||
alert('Не удалось отправить сообщение');
|
||
} finally {
|
||
setSendingMessage(false);
|
||
// Keep input focused after everything is done
|
||
setTimeout(() => {
|
||
messageInputRef.current?.focus();
|
||
}, 0);
|
||
}
|
||
};
|
||
|
||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendMessage();
|
||
}
|
||
};
|
||
|
||
const handleStartEdit = (message: Message) => {
|
||
setEditingMessageId(message.id);
|
||
setEditingMessageText(message.content);
|
||
};
|
||
|
||
const handleCancelEdit = () => {
|
||
setEditingMessageId(null);
|
||
setEditingMessageText('');
|
||
};
|
||
|
||
const handleSaveEdit = async (messageId: string) => {
|
||
if (!editingMessageText.trim() || sendingMessage) return;
|
||
|
||
try {
|
||
setSendingMessage(true);
|
||
|
||
const updatedMessage = await chatService.updateMessage({
|
||
id: messageId,
|
||
content: editingMessageText.trim(),
|
||
});
|
||
|
||
// Update message in list
|
||
setMessages(prev =>
|
||
prev.map(m => m.id === messageId ? updatedMessage : m)
|
||
);
|
||
|
||
// Clear editing state
|
||
setEditingMessageId(null);
|
||
setEditingMessageText('');
|
||
|
||
} catch (err: any) {
|
||
console.error('Failed to update message:', err);
|
||
alert('Не удалось изменить сообщение');
|
||
} finally {
|
||
setSendingMessage(false);
|
||
}
|
||
};
|
||
|
||
const handleEditKeyPress = (e: React.KeyboardEvent<HTMLInputElement>, messageId: string) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSaveEdit(messageId);
|
||
} else if (e.key === 'Escape') {
|
||
handleCancelEdit();
|
||
}
|
||
};
|
||
|
||
const handleDeleteMessage = async (messageId: string) => {
|
||
if (!confirm('Удалить сообщение?')) return;
|
||
|
||
try {
|
||
await chatService.deleteMessage(messageId);
|
||
// Message will be removed via WebSocket
|
||
} catch (err: any) {
|
||
console.error('Failed to delete message:', err);
|
||
alert('Не удалось удалить сообщение');
|
||
}
|
||
};
|
||
|
||
// User search handlers
|
||
const handleSearchUsers = async (query: string) => {
|
||
setSearchQuery(query);
|
||
|
||
// Clear previous timeout
|
||
if (searchTimeoutRef.current) {
|
||
clearTimeout(searchTimeoutRef.current);
|
||
}
|
||
|
||
if (query.trim().length < 2) {
|
||
setSearchResults([]);
|
||
setSearchLoading(false);
|
||
return;
|
||
}
|
||
|
||
setSearchLoading(true);
|
||
|
||
// Debounce search
|
||
searchTimeoutRef.current = setTimeout(async () => {
|
||
try {
|
||
const results = await userService.searchUsers(query);
|
||
setSearchResults(results);
|
||
} catch (err: any) {
|
||
console.error('Search failed:', err);
|
||
} finally {
|
||
setSearchLoading(false);
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
const handleStartChatWithUser = async (recipientUser: UserType) => {
|
||
try {
|
||
// Send initial message to create chat
|
||
const message = await chatService.sendMessage({
|
||
recipient_id: recipientUser.id,
|
||
content: `Привет, ${recipientUser.display_name || recipientUser.username}!`
|
||
});
|
||
|
||
// Close modal
|
||
setShowNewChatModal(false);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
|
||
// Reload chats to see the new one
|
||
const updatedChats = await chatService.getChats();
|
||
setChats(updatedChats);
|
||
setChatsCache(updatedChats);
|
||
|
||
// Find and select the new chat
|
||
const newChat = updatedChats.find(c =>
|
||
c.chat_id === message.chat_id
|
||
);
|
||
if (newChat) {
|
||
setSelectedChat(newChat);
|
||
navigate(`/chat/${newChat.chat_id}`);
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to start chat:', err);
|
||
alert('Не удалось начать чат');
|
||
}
|
||
};
|
||
|
||
|
||
return (
|
||
<div className="w-screen h-screen flex flex-col md:flex-row overflow-hidden" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||
{/* Sidebar */}
|
||
<div className={`w-screen md:w-80 flex flex-col h-screen shadow-soft ${
|
||
selectedChat ? 'hidden md:flex' : 'flex'
|
||
}`} style={{ backgroundColor: 'var(--bg-card)' }}>
|
||
{/* Header */}
|
||
<div className="p-2.5 md:p-6 border-b flex-shrink-0" style={{ borderColor: 'var(--border-color)' }}>
|
||
<h1 className="text-xl md:text-3xl font-lora font-semibold text-center tracking-wider" style={{ color: 'var(--accent-primary)' }}>
|
||
AETHER
|
||
</h1>
|
||
</div>
|
||
|
||
{/* Verification Banner */}
|
||
{user && !user.is_verified && (
|
||
<div className="px-3 py-2 md:p-4 flex-shrink-0">
|
||
<VerificationBanner userEmail={user.email} />
|
||
</div>
|
||
)}
|
||
|
||
{/* User Profile Section */}
|
||
<div className="px-3 py-3 md:p-6 border-b flex-shrink-0" style={{ borderColor: 'var(--border-color)' }}>
|
||
<div className="flex items-center gap-2 md:gap-4">
|
||
{/* Avatar */}
|
||
<div
|
||
className="w-10 h-10 md:w-14 md:h-14 flex items-center justify-center flex-shrink-0 cursor-pointer hover:opacity-90 transition"
|
||
style={{
|
||
backgroundImage: user?.avatar_url ? `url(${user.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
borderRadius: user?.avatar_url ? '50%' : '0',
|
||
}}
|
||
onClick={() => navigate('/profile')}
|
||
title="Перейти в профиль"
|
||
>
|
||
{!user?.avatar_url && (
|
||
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
|
||
)}
|
||
</div>
|
||
|
||
{/* User Info */}
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-inter font-semibold text-sm md:text-base truncate" style={{ color: 'var(--text-primary)' }}>
|
||
{user?.display_name || user?.username}
|
||
</h3>
|
||
<p className="text-xs md:text-sm font-inter truncate" style={{ color: 'var(--text-secondary)' }}>
|
||
@{user?.username}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Settings Icon */}
|
||
<button
|
||
onClick={() => navigate('/settings')}
|
||
className="p-1.5 md:p-2 hover:bg-gray-100 rounded-full transition min-h-touch min-w-touch"
|
||
title="Настройки"
|
||
>
|
||
<Settings size={18} style={{ color: 'var(--text-secondary)' }} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Action Button */}
|
||
<div className="mt-2 md:mt-4">
|
||
<motion.button
|
||
onClick={() => navigate('/profile')}
|
||
whileTap={{ scale: 0.95 }}
|
||
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl md:rounded-2xl font-inter text-xs md:text-sm font-medium transition hover:opacity-80 min-h-touch"
|
||
style={{ backgroundColor: 'var(--bg-input)', color: 'var(--accent-primary)' }}
|
||
>
|
||
<User size={14} className="md:w-4 md:h-4" />
|
||
Профиль
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* New Chat Button */}
|
||
<div className="px-3 py-2 md:p-4 flex-shrink-0">
|
||
<motion.button
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => setShowNewChatModal(true)}
|
||
className="w-full flex items-center justify-center gap-2 md:gap-3 py-2.5 md:py-3 px-3 md:px-4 rounded-xl md:rounded-2xl font-inter font-semibold text-sm md:text-base shadow-sm hover:shadow-md transition min-h-touch"
|
||
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
|
||
>
|
||
<MessageSquarePlus size={16} className="md:w-5 md:h-5" />
|
||
Новый чат
|
||
</motion.button>
|
||
</div>
|
||
|
||
{/* Chats List */}
|
||
<div className="flex-1 overflow-y-auto px-2 md:px-4">
|
||
<div className="space-y-1 md:space-y-2">
|
||
{loading ? (
|
||
<div className="text-center py-12 px-4">
|
||
<div className="animate-spin w-8 h-8 mx-auto mb-4 border-2 border-t-transparent rounded-full"
|
||
style={{ borderColor: 'var(--accent-primary)', borderTopColor: 'transparent' }}></div>
|
||
<p className="font-inter text-xs md:text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Загрузка чатов...
|
||
</p>
|
||
</div>
|
||
) : error ? (
|
||
<div className="text-center py-12 px-4">
|
||
<p className="font-inter text-xs md:text-sm text-red-500">{error}</p>
|
||
</div>
|
||
) : chats.length === 0 ? (
|
||
<div className="text-center py-12 px-4">
|
||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-accent-terracotta/20 to-accent-olive/20 flex items-center justify-center">
|
||
<MessageSquarePlus size={32} style={{ color: 'var(--accent-primary)', opacity: 0.5 }} />
|
||
</div>
|
||
<p className="font-inter text-xs md:text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Пока нет чатов
|
||
</p>
|
||
<p className="font-inter text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Начните новый диалог
|
||
</p>
|
||
</div>
|
||
) : (
|
||
chats.map((chat) => (
|
||
<motion.div
|
||
key={chat.chat_id}
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => handleChatClick(chat)}
|
||
className="p-2.5 md:p-4 rounded-lg md:rounded-2xl cursor-pointer transition-all relative overflow-hidden min-h-touch"
|
||
style={{
|
||
backgroundColor: selectedChat?.chat_id === chat.chat_id ? 'var(--accent-primary)' : 'var(--bg-input)',
|
||
boxShadow: selectedChat?.chat_id === chat.chat_id ? '0 4px 12px rgba(0,0,0,0.1)' : 'none',
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-2.5 md:gap-4">
|
||
{/* Chat Avatar with Online Indicator */}
|
||
<div className="relative flex-shrink-0">
|
||
<div
|
||
className="w-10 h-10 md:w-12 md:h-12 flex items-center justify-center"
|
||
style={{
|
||
backgroundImage: chat.avatar_url ? `url(${chat.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
borderRadius: chat.avatar_url ? '50%' : '8px',
|
||
}}
|
||
>
|
||
{!chat.avatar_url && (
|
||
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
|
||
)}
|
||
</div>
|
||
{/* Online indicator */}
|
||
<div
|
||
className="absolute bottom-0 right-0 w-3 h-3 md:w-4 md:h-4 rounded-full border-2"
|
||
style={{
|
||
backgroundColor: '#10b981',
|
||
borderColor: 'var(--bg-card)'
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Chat Info */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between gap-2 mb-1">
|
||
<h4
|
||
className="font-inter font-semibold text-sm md:text-base truncate"
|
||
style={{ color: selectedChat?.chat_id === chat.chat_id ? 'white' : 'var(--text-primary)' }}
|
||
>
|
||
{chat.display_name}
|
||
</h4>
|
||
{/* Time badge */}
|
||
<span
|
||
className="text-xs font-inter flex-shrink-0"
|
||
style={{
|
||
color: selectedChat?.chat_id === chat.chat_id ? 'rgba(255,255,255,0.7)' : 'var(--text-secondary)',
|
||
fontSize: '0.7rem'
|
||
}}
|
||
>
|
||
12:34
|
||
</span>
|
||
</div>
|
||
|
||
{chat.last_message ? (
|
||
<p
|
||
className="font-inter text-xs md:text-sm truncate"
|
||
style={{ color: selectedChat?.chat_id === chat.chat_id ? 'rgba(255,255,255,0.85)' : 'var(--text-secondary)' }}
|
||
>
|
||
{chat.last_message}
|
||
</p>
|
||
) : (
|
||
<p
|
||
className="font-inter text-xs md:text-sm italic truncate"
|
||
style={{ color: selectedChat?.chat_id === chat.chat_id ? 'rgba(255,255,255,0.6)' : 'var(--text-secondary)' }}
|
||
>
|
||
Нет сообщений
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer Info */}
|
||
<div className="p-2 md:p-4 border-t text-center flex-shrink-0" style={{ borderColor: 'var(--border-color)' }}>
|
||
<p className="text-xs font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
Aether Chat v1.0
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Chat Area */}
|
||
<div className={`flex-1 flex flex-col h-screen relative ${
|
||
selectedChat ? 'flex' : 'hidden md:flex'
|
||
}`}>
|
||
{selectedChat ? (
|
||
<>
|
||
{/* Chat Header */}
|
||
<div className="px-2 md:px-4 py-2 md:py-4 border-b flex items-center gap-2 md:gap-4 flex-shrink-0" style={{
|
||
backgroundColor: 'var(--bg-card)',
|
||
borderColor: 'var(--border-color)'
|
||
}}>
|
||
<motion.button
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
onClick={handleBackToChats}
|
||
className="p-2 rounded-full transition min-h-touch min-w-touch"
|
||
style={{
|
||
backgroundColor: 'var(--bg-input)',
|
||
color: 'var(--text-primary)'
|
||
}}
|
||
title="Назад к чатам"
|
||
>
|
||
<ArrowLeft size={18} className="md:w-5 md:h-5" />
|
||
</motion.button>
|
||
|
||
<motion.div
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={() => handleViewUserProfile(selectedChat.user_id)}
|
||
className="w-10 h-10 md:w-12 md:h-12 flex-shrink-0 flex items-center justify-center cursor-pointer"
|
||
style={{
|
||
backgroundImage: selectedChat.avatar_url ? `url(${selectedChat.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
borderRadius: selectedChat.avatar_url ? '50%' : '0',
|
||
}}
|
||
>
|
||
{!selectedChat.avatar_url && (
|
||
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
|
||
)}
|
||
</motion.div>
|
||
|
||
<div
|
||
className="flex-1 min-w-0 cursor-pointer"
|
||
onClick={() => handleViewUserProfile(selectedChat.user_id)}
|
||
>
|
||
<h3 className="font-inter font-semibold text-sm md:text-base truncate hover:underline" style={{ color: 'var(--text-primary)' }}>
|
||
{selectedChat.display_name}
|
||
</h3>
|
||
<p className="text-xs font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
в сети
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages Area */}
|
||
<div
|
||
ref={messagesContainerRef}
|
||
onScroll={handleScroll}
|
||
className="flex-1 overflow-y-auto px-3 md:px-6 py-4 md:py-6 relative"
|
||
style={{
|
||
backgroundColor: 'var(--bg-primary)',
|
||
backgroundImage: `
|
||
radial-gradient(circle at 10% 20%, rgba(166, 123, 91, 0.08) 0%, transparent 50%),
|
||
radial-gradient(circle at 90% 80%, rgba(119, 141, 109, 0.08) 0%, transparent 50%),
|
||
repeating-linear-gradient(
|
||
0deg,
|
||
transparent,
|
||
transparent 50px,
|
||
rgba(166, 123, 91, 0.02) 50px,
|
||
rgba(166, 123, 91, 0.02) 51px
|
||
)
|
||
`
|
||
}}
|
||
>
|
||
{loadingMore && (
|
||
<div className="flex justify-center py-2">
|
||
<div className="animate-spin w-6 h-6 border-2 border-t-transparent rounded-full"
|
||
style={{ borderColor: 'var(--accent-primary)', borderTopColor: 'transparent' }}></div>
|
||
</div>
|
||
)}
|
||
{messagesLoading ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="animate-spin w-8 h-8 border-2 border-t-transparent rounded-full"
|
||
style={{ borderColor: 'var(--accent-primary)', borderTopColor: 'transparent' }}></div>
|
||
</div>
|
||
) : messagesError ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<p className="font-inter text-sm text-red-500">{messagesError}</p>
|
||
</div>
|
||
) : messages.length === 0 ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="text-center">
|
||
<div className="w-24 h-24 mx-auto mb-6 rounded-3xl bg-gradient-to-br from-accent-terracotta/15 to-accent-olive/15 flex items-center justify-center shadow-lg">
|
||
<MessageSquarePlus size={42} style={{ color: 'var(--accent-primary)', opacity: 0.5 }} />
|
||
</div>
|
||
<h3 className="font-lora text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
Начните общение
|
||
</h3>
|
||
<p className="font-inter text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Отправьте первое сообщение в этот чат
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1 max-w-4xl mx-auto">
|
||
{messages.map((message, index) => {
|
||
const isMyMessage = message.sender_id === user?.id;
|
||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||
const showAvatar = !prevMessage || prevMessage.sender_id !== message.sender_id;
|
||
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
|
||
const isLastInGroup = !nextMessage || nextMessage.sender_id !== message.sender_id;
|
||
|
||
// Check if date changed from previous message
|
||
const messageDate = new Date(message.created_at);
|
||
const prevMessageDate = prevMessage ? new Date(prevMessage.created_at) : null;
|
||
const showDateDivider = !prevMessageDate ||
|
||
messageDate.toDateString() !== prevMessageDate.toDateString();
|
||
|
||
return (
|
||
<div key={message.id}>
|
||
{/* Date divider */}
|
||
{showDateDivider && (
|
||
<div className="flex items-center justify-center my-4 md:my-6">
|
||
<div className="px-3 md:px-4 py-1.5 md:py-2 rounded-full font-inter text-xs font-medium shadow-sm"
|
||
style={{
|
||
backgroundColor: 'var(--bg-card)',
|
||
color: 'var(--text-secondary)',
|
||
border: '1px solid var(--border-color)'
|
||
}}>
|
||
{messageDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||
className={`flex gap-2 md:gap-3 mb-2 md:mb-3 ${isMyMessage ? 'justify-end' : 'justify-start'} ${!showAvatar && !isMyMessage ? 'ml-8 md:ml-11' : ''}`}
|
||
>
|
||
{/* Avatar for incoming messages */}
|
||
{!isMyMessage && showAvatar && (
|
||
<motion.div
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
transition={{ delay: 0.1 }}
|
||
className="w-7 h-7 md:w-9 md:h-9 flex-shrink-0 flex items-center justify-center self-end shadow-md"
|
||
style={{
|
||
backgroundImage: selectedChat.avatar_url ? `url(${selectedChat.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
borderRadius: selectedChat.avatar_url ? '50%' : '8px',
|
||
border: '2px solid var(--bg-card)'
|
||
}}
|
||
>
|
||
{!selectedChat.avatar_url && (
|
||
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain p-1" />
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Message bubble */}
|
||
<div className="flex flex-col max-w-xs md:max-w-lg">
|
||
{/* Sender name for incoming messages */}
|
||
{!isMyMessage && showAvatar && (
|
||
<span className="text-xs font-inter font-medium mb-0.5 md:mb-1 px-2" style={{ color: 'var(--accent-primary)' }}>
|
||
{selectedChat.display_name}
|
||
</span>
|
||
)}
|
||
|
||
<motion.div
|
||
whileHover={{ scale: 1.02 }}
|
||
className={`px-3 md:px-5 py-2 md:py-3 font-inter text-sm md:text-[15px] leading-relaxed relative group ${
|
||
isMyMessage
|
||
? 'rounded-2xl md:rounded-[20px] rounded-br-sm'
|
||
: 'rounded-2xl md:rounded-[20px] rounded-bl-sm'
|
||
}`}
|
||
style={{
|
||
backgroundColor: isMyMessage ? 'var(--accent-primary)' : 'var(--bg-card)',
|
||
color: isMyMessage ? 'white' : 'var(--text-primary)',
|
||
boxShadow: isMyMessage
|
||
? '0 2px 8px rgba(166, 123, 91, 0.25)'
|
||
: '0 2px 8px rgba(0,0,0,0.08)',
|
||
border: isMyMessage ? 'none' : '1px solid var(--border-color)'
|
||
}}
|
||
>
|
||
{/* Edit and Delete buttons - только для своих сообщений на десктопе */}
|
||
{isMyMessage && editingMessageId !== message.id && (
|
||
<>
|
||
<button
|
||
onClick={() => handleStartEdit(message)}
|
||
className="absolute -top-2 -right-10 p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-md hidden md:block"
|
||
style={{ backgroundColor: 'var(--bg-card)', color: 'var(--accent-primary)' }}
|
||
title="Редактировать"
|
||
>
|
||
<Pencil size={12} className="md:w-3.5 md:h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteMessage(message.id)}
|
||
className="absolute -top-2 -right-2 p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-md hidden md:block"
|
||
style={{ backgroundColor: 'var(--bg-card)', color: '#DC2626' }}
|
||
title="Удалить"
|
||
>
|
||
<Trash2 size={12} className="md:w-3.5 md:h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Message content or edit input */}
|
||
{editingMessageId === message.id ? (
|
||
<div className="flex items-center gap-1 md:gap-2">
|
||
<input
|
||
type="text"
|
||
value={editingMessageText}
|
||
onChange={(e) => setEditingMessageText(e.target.value)}
|
||
onKeyDown={(e) => handleEditKeyPress(e, message.id)}
|
||
autoFocus
|
||
className="flex-1 bg-transparent border-b-2 outline-none text-sm md:text-base"
|
||
style={{
|
||
borderColor: isMyMessage ? 'rgba(255,255,255,0.5)' : 'var(--accent-primary)',
|
||
color: isMyMessage ? 'white' : 'var(--text-primary)'
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => handleSaveEdit(message.id)}
|
||
className="p-1 hover:opacity-70 transition min-w-touch min-h-touch flex items-center justify-center"
|
||
title="Сохранить (Enter)"
|
||
>
|
||
<Check size={14} className="md:w-4 md:h-4" />
|
||
</button>
|
||
<button
|
||
onClick={handleCancelEdit}
|
||
className="p-1 hover:opacity-70 transition min-w-touch min-h-touch flex items-center justify-center"
|
||
title="Отмена (Esc)"
|
||
>
|
||
<X size={14} className="md:w-4 md:h-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<p className="break-words whitespace-pre-wrap">{message.content}</p>
|
||
)}
|
||
|
||
{/* Message metadata */}
|
||
<div className={`flex items-center gap-1 md:gap-2 mt-0.5 md:mt-1 ${isMyMessage ? 'justify-end' : 'justify-start'}`}>
|
||
{/* Edited indicator */}
|
||
{message.is_edited && (
|
||
<span
|
||
className="text-[10px] md:text-[11px] font-inter font-medium italic"
|
||
style={{
|
||
color: isMyMessage ? 'rgba(255,255,255,0.6)' : 'var(--text-secondary)',
|
||
}}
|
||
>
|
||
изменено
|
||
</span>
|
||
)}
|
||
|
||
<span
|
||
className="text-[10px] md:text-[11px] font-inter font-medium"
|
||
style={{
|
||
color: isMyMessage ? 'rgba(255,255,255,0.75)' : 'var(--text-secondary)',
|
||
}}
|
||
>
|
||
{new Date(message.created_at).toLocaleTimeString('ru-RU', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
|
||
{/* Read status for my messages */}
|
||
{isMyMessage && (
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
style={{ opacity: 0.75 }}
|
||
>
|
||
<path
|
||
d="M3 8L6 11L13 4"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M5 8L8 11L15 4"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Reactions placeholder */}
|
||
{isLastInGroup && false && (
|
||
<div className="flex gap-1 mt-1 px-2">
|
||
<span className="text-xs">❤️</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Invisible element to scroll to */}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Scroll to bottom button with unread counter - OUTSIDE scroll container */}
|
||
<AnimatePresence>
|
||
{showScrollButton && (
|
||
<motion.button
|
||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||
onClick={() => scrollToBottom(true)}
|
||
className="absolute bottom-20 md:bottom-24 right-4 md:right-8 p-2.5 md:p-3 rounded-full shadow-xl hover:scale-110 transition-transform"
|
||
style={{
|
||
backgroundColor: 'var(--accent-primary)',
|
||
color: 'white',
|
||
zIndex: 50
|
||
}}
|
||
title="Прокрутить вниз"
|
||
>
|
||
<ArrowDown size={20} />
|
||
{unreadCount > 0 && (
|
||
<motion.div
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="absolute -top-2 -right-2 min-w-[22px] h-[22px] px-1.5 rounded-full flex items-center justify-center text-xs font-bold shadow-md border-2"
|
||
style={{
|
||
backgroundColor: '#DC2626',
|
||
color: 'white',
|
||
borderColor: 'var(--bg-card)'
|
||
}}
|
||
>
|
||
{unreadCount > 99 ? '99+' : unreadCount}
|
||
</motion.div>
|
||
)}
|
||
</motion.button>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Message Input */}
|
||
<div className="px-2 md:px-4 py-2 md:py-4 border-t flex-shrink-0" style={{
|
||
backgroundColor: 'var(--bg-card)',
|
||
borderColor: 'var(--border-color)'
|
||
}}>
|
||
<div className="flex gap-2 md:gap-3 max-w-4xl mx-auto">
|
||
<input
|
||
ref={messageInputRef}
|
||
type="text"
|
||
value={messageText}
|
||
onChange={(e) => setMessageText(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="Сообщение..."
|
||
disabled={sendingMessage}
|
||
className="flex-1 px-3 md:px-4 py-2 md:py-3 rounded-lg md:rounded-2xl font-inter text-sm md:text-base outline-none transition min-h-touch"
|
||
style={{
|
||
backgroundColor: 'var(--bg-input)',
|
||
color: 'var(--text-primary)',
|
||
borderBottom: '2px solid transparent',
|
||
fontSize: '16px', // Prevent iOS zoom on input focus
|
||
}}
|
||
onFocus={(e) => e.target.style.borderBottomColor = 'var(--accent-primary)'}
|
||
onBlur={(e) => e.target.style.borderBottomColor = 'transparent'}
|
||
/>
|
||
<motion.button
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={handleSendMessage}
|
||
disabled={!messageText.trim() || sendingMessage}
|
||
className="px-3 md:px-6 py-2 md:py-3 rounded-lg md:rounded-2xl font-inter font-semibold flex items-center justify-center gap-1 md:gap-2 transition hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed min-h-touch min-w-touch"
|
||
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
|
||
>
|
||
{sendingMessage ? (
|
||
<div className="animate-spin w-4 h-4 md:w-5 md:h-5 border-2 border-white border-t-transparent rounded-full"></div>
|
||
) : (
|
||
<Send size={16} className="md:w-4.5 md:h-4.5" />
|
||
)}
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* Empty State */
|
||
<div className="flex-1 flex items-center justify-center p-8">
|
||
<div className="text-center max-w-md">
|
||
<motion.div
|
||
initial={{ scale: 0.9, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<div className="w-32 h-32 mx-auto mb-6 flex items-center justify-center">
|
||
<img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
|
||
</div>
|
||
<h2 className="text-2xl font-lora font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||
Добро пожаловать в Aether
|
||
</h2>
|
||
<p className="font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
Выберите существующий чат из списка слева или создайте новый, чтобы начать общение
|
||
</p>
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* User Profile Modal */}
|
||
<AnimatePresence>
|
||
{viewingUser && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black/50 flex items-end md:items-center justify-center z-50 p-4"
|
||
onClick={() => setViewingUser(null)}
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.9, opacity: 0, y: 100 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
exit={{ scale: 0.9, opacity: 0, y: 100 }}
|
||
transition={{ type: "spring", duration: 0.3 }}
|
||
className="rounded-t-3xl md:rounded-3xl shadow-2xl w-full md:max-w-md overflow-hidden"
|
||
style={{ backgroundColor: 'var(--bg-card)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Modal Header */}
|
||
<div className="relative p-4 md:p-6 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||
<button
|
||
onClick={() => setViewingUser(null)}
|
||
className="absolute top-3 md:top-4 right-3 md:right-4 p-2 rounded-full hover:bg-gray-100 transition min-h-touch min-w-touch"
|
||
style={{ color: 'var(--text-secondary)' }}
|
||
>
|
||
<X size={18} className="md:w-5 md:h-5" />
|
||
</button>
|
||
<h2 className="text-lg md:text-xl font-lora font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||
Профиль пользователя
|
||
</h2>
|
||
</div>
|
||
|
||
{/* Modal Content */}
|
||
<div className="p-4 md:p-6">
|
||
{/* Avatar */}
|
||
<div className="flex justify-center mb-4 md:mb-6">
|
||
<div
|
||
className="w-24 h-24 md:w-32 md:h-32 rounded-full flex items-center justify-center shadow-lg"
|
||
style={{
|
||
backgroundImage: viewingUser.avatar_url ? `url(${viewingUser.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
backgroundColor: viewingUser.avatar_url ? undefined : 'var(--bg-input)',
|
||
}}
|
||
>
|
||
{!viewingUser.avatar_url && (
|
||
<User size={36} className="md:w-12 md:h-12" style={{ color: 'var(--text-secondary)' }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* User Info */}
|
||
<div className="space-y-3 md:space-y-4">
|
||
<div className="text-center">
|
||
<h3 className="text-lg md:text-2xl font-lora font-semibold mb-0.5 md:mb-1" style={{ color: 'var(--text-primary)' }}>
|
||
{viewingUser.display_name}
|
||
</h3>
|
||
<p className="text-xs md:text-sm font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
@{viewingUser.username}
|
||
</p>
|
||
</div>
|
||
|
||
{viewingUser.description && (
|
||
<div className="p-3 md:p-4 rounded-lg md:rounded-2xl" style={{ backgroundColor: 'var(--bg-input)' }}>
|
||
<p className="text-sm md:text-base font-inter" style={{ color: 'var(--text-primary)' }}>
|
||
{viewingUser.description}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{viewingUser.birth_day && (
|
||
<div className="flex items-center gap-2 text-xs md:text-sm font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
<span>🎂</span>
|
||
<span>
|
||
{new Date(viewingUser.birth_day).toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
})}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 text-xs md:text-sm font-inter" style={{ color: 'var(--text-secondary)' }}>
|
||
<span>✉️</span>
|
||
<span className="truncate">{viewingUser.email}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="mt-4 md:mt-6 pt-4 md:pt-6 border-t" style={{ borderColor: 'var(--border-color)' }}>
|
||
<motion.button
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={() => setViewingUser(null)}
|
||
className="w-full py-2.5 md:py-3 px-4 rounded-lg md:rounded-2xl font-inter font-semibold transition hover:opacity-90 min-h-touch"
|
||
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
|
||
>
|
||
Закрыть
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* New Chat Modal */}
|
||
<AnimatePresence>
|
||
{showNewChatModal && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black/50 flex items-end md:items-center justify-center z-50 p-4"
|
||
onClick={() => {
|
||
setShowNewChatModal(false);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
}}
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.95, opacity: 0, y: 100 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
exit={{ scale: 0.95, opacity: 0, y: 100 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full md:max-w-md rounded-t-3xl md:rounded-3xl shadow-2xl overflow-hidden"
|
||
style={{ backgroundColor: 'var(--bg-card)' }}
|
||
>
|
||
{/* Modal Header */}
|
||
<div className="p-4 md:p-6 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||
<h2 className="text-lg md:text-2xl font-lora font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||
Новый чат
|
||
</h2>
|
||
<button
|
||
onClick={() => {
|
||
setShowNewChatModal(false);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
}}
|
||
className="p-2 rounded-full hover:bg-gray-100 transition min-h-touch min-w-touch"
|
||
>
|
||
<X size={18} className="md:w-5 md:h-5" style={{ color: 'var(--text-secondary)' }} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Input */}
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||
placeholder="Поиск пользователей..."
|
||
autoFocus
|
||
className="w-full px-3 md:px-4 py-2 md:py-3 rounded-lg md:rounded-2xl font-inter text-sm md:text-base outline-none transition"
|
||
style={{
|
||
backgroundColor: 'var(--bg-input)',
|
||
color: 'var(--text-primary)',
|
||
borderBottom: '2px solid transparent',
|
||
fontSize: '16px', // Prevent iOS zoom
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Search Results */}
|
||
<div className="max-h-96 overflow-y-auto p-3 md:p-4">
|
||
{searchLoading ? (
|
||
<div className="text-center py-8">
|
||
<div className="animate-spin w-8 h-8 mx-auto mb-4 border-2 border-t-transparent rounded-full"
|
||
style={{ borderColor: 'var(--accent-primary)', borderTopColor: 'transparent' }}></div>
|
||
<p className="font-inter text-xs md:text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Поиск...
|
||
</p>
|
||
</div>
|
||
) : searchQuery.trim().length < 2 ? (
|
||
<div className="text-center py-8">
|
||
<p className="font-inter text-xs md:text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Введите имя пользователя для поиска
|
||
</p>
|
||
</div>
|
||
) : searchResults.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<p className="font-inter text-xs md:text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
Пользователи не найдены
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5 md:space-y-2">
|
||
{searchResults.map((foundUser) => (
|
||
<motion.button
|
||
key={foundUser.id}
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => handleStartChatWithUser(foundUser)}
|
||
className="w-full flex items-center gap-2 md:gap-3 p-2.5 md:p-3 rounded-lg md:rounded-2xl transition hover:shadow-sm min-h-touch"
|
||
style={{ backgroundColor: 'var(--bg-input)' }}
|
||
>
|
||
{/* Avatar */}
|
||
<div
|
||
className="w-10 h-10 md:w-12 md:h-12 flex items-center justify-center flex-shrink-0"
|
||
style={{
|
||
backgroundImage: foundUser.avatar_url ? `url(${foundUser.avatar_url})` : undefined,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
borderRadius: foundUser.avatar_url ? '50%' : '0',
|
||
backgroundColor: foundUser.avatar_url ? 'transparent' : 'var(--bg-input)',
|
||
}}
|
||
>
|
||
{!foundUser.avatar_url && (
|
||
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
|
||
)}
|
||
</div>
|
||
|
||
{/* User Info */}
|
||
<div className="flex-1 text-left min-w-0">
|
||
<p className="font-inter font-semibold text-xs md:text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||
{foundUser.display_name || foundUser.username}
|
||
</p>
|
||
<p className="font-inter text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||
@{foundUser.username}
|
||
</p>
|
||
</div>
|
||
</motion.button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|