pre-deep-ddd-refactor
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './modules/auth/application/authStore';
|
||||||
import AuthPage from './pages/AuthPage';
|
import AuthPage from './modules/auth/presentation/AuthPage';
|
||||||
import ChatPage from './pages/ChatPage';
|
import ChatPage from './modules/chats/presentation/ChatPage';
|
||||||
import AdminPage from './pages/AdminPage';
|
import AdminPage from './pages/AdminPage';
|
||||||
import NotificationProvider from './components/NotificationProvider';
|
import NotificationProvider from './components/NotificationProvider';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { api } from '../lib/api';
|
import { UserApi } from '../modules/users/infrastructure/userApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds';
|
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds';
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ async function getIceServers(): Promise<RTCConfiguration> {
|
|||||||
return cachedIceConfig;
|
return cachedIceConfig;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api.getIceServers();
|
const data = await UserApi.getIceServers();
|
||||||
console.log('[WebRTC] Received ICE config:', data);
|
console.log('[WebRTC] Received ICE config:', data);
|
||||||
if (data.iceServers && data.iceServers.length > 0) {
|
if (data.iceServers && data.iceServers.length > 0) {
|
||||||
cachedIceConfig = { iceServers: data.iceServers };
|
cachedIceConfig = { iceServers: data.iceServers };
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Picker from '@emoji-mart/react';
|
|||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
import { api } from '../lib/api';
|
import { AppApi } from '../lib/appApi';
|
||||||
|
|
||||||
interface KlipyGif {
|
interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,7 +52,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
|
if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
|
||||||
initialFetchDone.current = true;
|
initialFetchDone.current = true;
|
||||||
setGifLoading(true);
|
setGifLoading(true);
|
||||||
api.getTrendingGifs()
|
AppApi.getTrendingGifs()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setTrendingGifs(extractGifs(d));
|
setTrendingGifs(extractGifs(d));
|
||||||
setGifLoading(false);
|
setGifLoading(false);
|
||||||
@@ -72,7 +72,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGifLoading(true);
|
setGifLoading(true);
|
||||||
api.searchKlipyGifs(q)
|
AppApi.searchKlipyGifs(q)
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setGifs(extractGifs(d));
|
setGifs(extractGifs(d));
|
||||||
setGifLoading(false);
|
setGifLoading(false);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Search } from 'lucide-react';
|
import { X, Search } from 'lucide-react';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../modules/chats/application/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../modules/chats/application/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { getMediaUrl } from '../lib/utils';
|
import { getMediaUrl } from '../lib/utils';
|
||||||
import { api } from '../lib/api';
|
import { UserApi } from '../modules/users/infrastructure/userApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
|
|
||||||
interface ParticipantInfo {
|
interface ParticipantInfo {
|
||||||
@@ -46,7 +46,7 @@ const FALLBACK_ICE: RTCConfiguration = {
|
|||||||
async function getIceServers(): Promise<RTCConfiguration> {
|
async function getIceServers(): Promise<RTCConfiguration> {
|
||||||
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig;
|
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig;
|
||||||
try {
|
try {
|
||||||
const data = await api.getIceServers();
|
const data = await UserApi.getIceServers();
|
||||||
if (data.iceServers?.length > 0) {
|
if (data.iceServers?.length > 0) {
|
||||||
cachedIceConfig = { iceServers: data.iceServers };
|
cachedIceConfig = { iceServers: data.iceServers };
|
||||||
iceCacheFetchedAt = Date.now();
|
iceCacheFetchedAt = Date.now();
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Eye,
|
Eye,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useFriendStore } from '../modules/friends/application/friendStore';
|
||||||
import { api } from '../lib/api';
|
import { useChatStore } from '../modules/chats/application/chatStore';
|
||||||
|
import { UserApi } from '../modules/users/infrastructure/userApi';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { useThemeStore, ChatTheme } from '../stores/themeStore';
|
import { useThemeStore, ChatTheme } from '../stores/themeStore';
|
||||||
@@ -61,12 +62,23 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
// Friends state
|
// Friends state
|
||||||
const [friends, setFriends] = useState<FriendWithId[]>([]);
|
const {
|
||||||
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([]);
|
friends,
|
||||||
const [friendsLoading, setFriendsLoading] = useState(false);
|
friendRequests,
|
||||||
const [friendSearch, setFriendSearch] = useState('');
|
isLoading: friendsLoading,
|
||||||
const [friendSearchResults, setFriendSearchResults] = useState<UserPresence[]>([]);
|
searchQuery: friendSearch,
|
||||||
const [friendSearchLoading, setFriendSearchLoading] = useState(false);
|
searchResults: friendSearchResults,
|
||||||
|
isSearching: friendSearchLoading,
|
||||||
|
setSearchQuery: setFriendSearch,
|
||||||
|
loadFriends,
|
||||||
|
acceptRequest: handleAcceptRequest,
|
||||||
|
declineRequest: handleDeclineRequest,
|
||||||
|
removeFriend: handleRemoveFriend,
|
||||||
|
sendRequest: handleSendFriendRequest,
|
||||||
|
searchFriends,
|
||||||
|
clearSearch,
|
||||||
|
initializeSocketEvents
|
||||||
|
} = useFriendStore();
|
||||||
|
|
||||||
const themeCards: { id: ChatTheme; color: string; accent: string; name: string; nameEn: string; desc: string; descEn: string; animated?: boolean; gradient?: string }[] = [
|
const themeCards: { id: ChatTheme; color: string; accent: string; name: string; nameEn: string; desc: string; descEn: string; animated?: boolean; gradient?: string }[] = [
|
||||||
{ id: 'midnight', color: '#0f0f13', accent: '#6366f1', name: 'Полночь', nameEn: 'Midnight', desc: 'Тёмная тема с мягкими акцентами', descEn: 'Dark theme with soft accents' },
|
{ id: 'midnight', color: '#0f0f13', accent: '#6366f1', name: 'Полночь', nameEn: 'Midnight', desc: 'Тёмная тема с мягкими акцентами', descEn: 'Dark theme with soft accents' },
|
||||||
@@ -93,143 +105,29 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFriends = async () => {
|
|
||||||
setFriendsLoading(true);
|
|
||||||
try {
|
|
||||||
const [friendsList, requests] = await Promise.all([
|
|
||||||
api.getFriends(),
|
|
||||||
api.getFriendRequests(),
|
|
||||||
]);
|
|
||||||
setFriends(friendsList);
|
|
||||||
setFriendRequests(requests);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Load friends error:', e);
|
|
||||||
} finally {
|
|
||||||
setFriendsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAcceptRequest = async (requestId: string) => {
|
|
||||||
try {
|
|
||||||
await api.acceptFriendRequest(requestId);
|
|
||||||
const req = friendRequests.find(r => r.id === requestId);
|
|
||||||
if (req) {
|
|
||||||
const socket = getSocket();
|
|
||||||
if (socket) socket.emit('friend_accepted', { friendId: req.user.id });
|
|
||||||
}
|
|
||||||
loadFriends();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeclineRequest = async (requestId: string) => {
|
|
||||||
try {
|
|
||||||
await api.declineFriendRequest(requestId);
|
|
||||||
setFriendRequests(prev => prev.filter(r => r.id !== requestId));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFriend = async (friendshipId: string) => {
|
|
||||||
try {
|
|
||||||
const friend = friends.find(f => f.friendshipId === friendshipId);
|
|
||||||
await api.removeFriend(friendshipId);
|
|
||||||
if (friend) {
|
|
||||||
const socket = getSocket();
|
|
||||||
if (socket) socket.emit('friend_removed', { friendId: friend.id });
|
|
||||||
}
|
|
||||||
setFriends(prev => prev.filter(f => f.friendshipId !== friendshipId));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendFriendRequest = async (friendId: string) => {
|
|
||||||
try {
|
|
||||||
const result = await api.sendFriendRequest(friendId);
|
|
||||||
const socket = getSocket();
|
|
||||||
if (socket) socket.emit('friend_request', { friendId });
|
|
||||||
// If auto-accepted (they already sent us a request), reload friends
|
|
||||||
if (result.status === 'accepted') {
|
|
||||||
loadFriends();
|
|
||||||
}
|
|
||||||
// Remove from search results
|
|
||||||
setFriendSearchResults(prev => prev.filter(u => u.id !== friendId));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Friend search effect
|
// Friend search effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = friendSearch.trim();
|
const timer = setTimeout(() => {
|
||||||
const q = raw.startsWith('@') ? raw.slice(1) : raw;
|
searchFriends(friendSearch, user?.id);
|
||||||
if (q.length < 3) {
|
|
||||||
setFriendSearchResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
setFriendSearchLoading(true);
|
|
||||||
const results = await api.searchUsers(q);
|
|
||||||
// Filter out self and already-friends
|
|
||||||
const friendIds = new Set(friends.map(f => f.id));
|
|
||||||
setFriendSearchResults(results.filter(u => u.id !== user?.id && !friendIds.has(u.id)));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setFriendSearchLoading(false);
|
|
||||||
}
|
|
||||||
}, 400);
|
}, 400);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [friendSearch, friends, user?.id]);
|
}, [friendSearch, user?.id, searchFriends]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
||||||
setFriendSearch('');
|
clearSearch();
|
||||||
setFriendSearchResults([]);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
// Load friend request count when menu opens
|
// Load friend request count when menu opens
|
||||||
api.getFriendRequests().then(setFriendRequests).catch(() => {});
|
useFriendStore.getState().loadFriends();
|
||||||
}, [isOpen]);
|
}, [isOpen, clearSearch]);
|
||||||
|
|
||||||
// Real-time friend updates via socket
|
// Real-time friend updates via socket
|
||||||
const loadFriendsRef = useRef(loadFriends);
|
|
||||||
loadFriendsRef.current = loadFriends;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const cleanup = initializeSocketEvents();
|
||||||
if (!socket) return;
|
return cleanup;
|
||||||
|
}, [initializeSocketEvents]);
|
||||||
const onFriendRequestReceived = () => {
|
|
||||||
// Reload friend requests when a new request arrives
|
|
||||||
api.getFriendRequests().then(setFriendRequests).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFriendRequestAccepted = () => {
|
|
||||||
// Someone accepted our request — reload friends
|
|
||||||
loadFriendsRef.current();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFriendRemoved = (data: { userId: string }) => {
|
|
||||||
// Remove this user from our friends list
|
|
||||||
setFriends(prev => prev.filter(f => f.id !== data.userId));
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('friend_request_received', onFriendRequestReceived);
|
|
||||||
socket.on('friend_request_accepted', onFriendRequestAccepted);
|
|
||||||
socket.on('friend_removed', onFriendRemoved);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off('friend_request_received', onFriendRequestReceived);
|
|
||||||
socket.off('friend_request_accepted', onFriendRequestAccepted);
|
|
||||||
socket.off('friend_removed', onFriendRemoved);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearStore();
|
clearStore();
|
||||||
@@ -411,7 +309,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const newVal = !user?.hideStoryViews;
|
const newVal = !user?.hideStoryViews;
|
||||||
try {
|
try {
|
||||||
await api.updateSettings({ hideStoryViews: newVal });
|
await UserApi.updateSettings({ hideStoryViews: newVal });
|
||||||
useAuthStore.getState().updateUser({ hideStoryViews: newVal });
|
useAuthStore.getState().updateUser({ hideStoryViews: newVal });
|
||||||
} catch {}
|
} catch {}
|
||||||
}}
|
}}
|
||||||
@@ -567,7 +465,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
const renderFriends = () => (
|
const renderFriends = () => (
|
||||||
<motion.div key="friends" 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 }}>
|
<motion.div key="friends" 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 }}>
|
||||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||||
<button onClick={() => { changeView('main'); setFriendSearch(''); setFriendSearchResults([]); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
<button onClick={() => { changeView('main'); clearSearch(); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<h3 className="text-sm font-semibold text-white flex-1">{t('friends')}</h3>
|
<h3 className="text-sm font-semibold text-white flex-1">{t('friends')}</h3>
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ import {
|
|||||||
X,
|
X,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../modules/chats/application/chatStore';
|
||||||
import { useNotificationStore } from '../stores/notificationStore';
|
import { useNotificationStore } from '../stores/notificationStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { api } from '../lib/api';
|
import { StoryApi } from '../modules/stories/infrastructure/storyApi';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import { StoryGroup } from '../lib/types';
|
import { StoryGroup } from '../lib/types';
|
||||||
import ChatListItem from './ChatListItem';
|
import ChatListItem from '../modules/chats/presentation/components/ChatListItem';
|
||||||
import NewChatModal from './NewChatModal';
|
import NewChatModal from '../modules/chats/presentation/components/NewChatModal';
|
||||||
import UserProfile from './UserProfile';
|
import UserProfile from '../modules/users/presentation/components/UserProfile';
|
||||||
import SideMenu from './SideMenu';
|
import SideMenu from './SideMenu';
|
||||||
import StoryViewer, { CreateStoryModal } from './StoryViewer';
|
import StoryViewer, { CreateStoryModal } from '../modules/stories/presentation/components/StoryViewer';
|
||||||
import { useStoryStore } from '../stores/useStoryStore';
|
import { useStoryStore } from '../modules/stories/application/storyStore';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export default function Sidebar() {
|
|||||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||||
|
|
||||||
const loadStories = () => {
|
const loadStories = () => {
|
||||||
api.getStories()
|
StoryApi.getStories()
|
||||||
.then(setStoryGroups)
|
.then(setStoryGroups)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Upload, Check, Loader2, MessageSquare, AlertCircle } from 'lucide-react';
|
import { X, Upload, Check, Loader2, MessageSquare, AlertCircle } from 'lucide-react';
|
||||||
import { api } from '../lib/api';
|
import { AppApi } from '../lib/appApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import type { User as UserType, FriendWithId } from '../lib/types';
|
import type { User as UserType, FriendWithId } from '../lib/types';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../modules/auth/application/authStore';
|
||||||
|
|
||||||
interface TelegramImportModalProps {
|
interface TelegramImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -41,7 +41,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.analyzeTelegramImport(selectedFile) as any;
|
const data = await AppApi.analyzeTelegramImport(selectedFile) as any;
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setNames(data.names);
|
setNames(data.names);
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.executeTelegramImport({ token, mapping, groupName }) as any;
|
const res = await AppApi.executeTelegramImport({ token, mapping, groupName }) as any;
|
||||||
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
|
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
|
||||||
setStep(3);
|
setStep(3);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
import type { User, UserBasic, UserPresence, Chat, Message, MediaItem, StoryGroup, FriendRequest, FriendWithId, FriendshipStatus } from './types';
|
|
||||||
|
|
||||||
const API_BASE = '/api';
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
private token: string | null = null;
|
|
||||||
|
|
||||||
setToken(token: string | null) {
|
|
||||||
this.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
|
||||||
const { timeout = 30_000, ...fetchOptions } = options;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
|
|
||||||
|
|
||||||
const isFormData = fetchOptions.body instanceof FormData;
|
|
||||||
const computedHeaders: Record<string, string> = {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
...(fetchOptions.headers as Record<string, string>),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isFormData && !computedHeaders['Content-Type']) {
|
|
||||||
computedHeaders['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
...fetchOptions,
|
|
||||||
headers: computedHeaders,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
throw new Error('Время ожидания запроса истекло');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// \u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
return this.request<{ token: string; user: User }>('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async register(username: string, displayName: string, password: string, bio?: string) {
|
|
||||||
return this.request<{ token: string; user: User }>('/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, displayName, password, bio }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMe() {
|
|
||||||
return this.request<{ user: User }>('/auth/me');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConfig() {
|
|
||||||
return this.request<any>('/config');
|
|
||||||
}
|
|
||||||
|
|
||||||
// \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438
|
|
||||||
async searchUsers(query: string) {
|
|
||||||
return this.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUser(id: string) {
|
|
||||||
return this.request<User>(`/users/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
|
|
||||||
return this.request<User>('/users/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadAvatar(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('avatar', file);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
||||||
const response = await fetch(`${API_BASE}/users/avatar`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Ошибка загрузки аватара');
|
|
||||||
return response.json() as Promise<User>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('avatar', file);
|
|
||||||
formData.append('cropX', cropData.x.toString());
|
|
||||||
formData.append('cropY', cropData.y.toString());
|
|
||||||
formData.append('cropWidth', cropData.width.toString());
|
|
||||||
formData.append('cropHeight', cropData.height.toString());
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/users/avatar/crop`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Ошибка кропа аватара');
|
|
||||||
return response.json() as Promise<User>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeAvatar() {
|
|
||||||
return this.request<User>('/users/avatar', { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchMessages(query: string, chatId?: string) {
|
|
||||||
const params = new URLSearchParams({ q: query });
|
|
||||||
if (chatId) params.append('chatId', chatId);
|
|
||||||
return this.request<Message[]>(`/messages/search?${params}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// \u0427\u0430\u0442\u044b
|
|
||||||
async getChats() {
|
|
||||||
return this.request<Chat[]>('/chats');
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPersonalChat(userId: string) {
|
|
||||||
return this.request<Chat>('/chats/personal', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ userId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createGroupChat(name: string, memberIds: string[]) {
|
|
||||||
return this.request<Chat>('/chats/group', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ name, memberIds }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// \u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f
|
|
||||||
async getMessages(chatId: string, cursor?: string) {
|
|
||||||
const params = cursor ? `?cursor=${cursor}` : '';
|
|
||||||
return this.request<Message[]>(`/messages/chat/${chatId}${params}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadFile(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
||||||
const response = await fetch(`${API_BASE}/messages/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0430\u0439\u043b\u0430');
|
|
||||||
return response.json() as Promise<{ url: string; filename: string; size: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// \u0413\u0440\u0443\u043f\u043f\u044b
|
|
||||||
async updateGroup(chatId: string, data: { name?: string; description?: string }) {
|
|
||||||
return this.request<Chat>(`/chats/${chatId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadGroupAvatar(chatId: string, file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('avatar', file);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
||||||
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0430\u0432\u0430\u0442\u0430\u0440\u0430');
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async addGroupMembers(chatId: string, userIds: string[]) {
|
|
||||||
return this.request<Chat>(`/chats/${chatId}/members`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ userIds }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeGroupMember(chatId: string, userId: string) {
|
|
||||||
return this.request<Chat>(`/chats/${chatId}/members/${userId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearChat(chatId: string) {
|
|
||||||
return this.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteChat(chatId: string) {
|
|
||||||
return this.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async togglePinChat(chatId: string) {
|
|
||||||
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
|
|
||||||
return this.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Stories
|
|
||||||
async getStories() {
|
|
||||||
return this.request<StoryGroup[]>('/stories');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserStories(userId: string) {
|
|
||||||
return this.request<StoryGroup>(`/stories/user/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
|
||||||
return this.request<{ id: string }>('/stories', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadVideoToStory(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/stories/video`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Ошибка загрузки видео истории');
|
|
||||||
return response.json() as Promise<{ url: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async viewStory(storyId: string) {
|
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteStory(storyId: string) {
|
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStoryViewers(storyId: string) {
|
|
||||||
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addStoryReaction(storyId: string, emoji: string) {
|
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ emoji }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeStoryReaction(storyId: string, emoji: string) {
|
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({ emoji }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addStoryReply(storyId: string, content: string) {
|
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}/reply`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStoryReplies(storyId: string) {
|
|
||||||
return this.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorites chat
|
|
||||||
async getOrCreateFavorites() {
|
|
||||||
return this.request<Chat>('/chats/favorites', { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// User settings
|
|
||||||
async updateSettings(settings: any) {
|
|
||||||
const res = await this.request('/users/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(settings),
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async analyzeTelegramImport(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const res = await this.request('/import/telegram/analyze', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
|
|
||||||
return this.request('/import/telegram/execute', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Friends
|
|
||||||
async getFriends() {
|
|
||||||
return this.request<FriendWithId[]>('/friends');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFriendRequests() {
|
|
||||||
return this.request<FriendRequest[]>('/friends/requests');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOutgoingRequests() {
|
|
||||||
return this.request<FriendRequest[]>('/friends/outgoing');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFriendshipStatus(userId: string) {
|
|
||||||
return this.request<FriendshipStatus>(`/friends/status/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendFriendRequest(friendId: string) {
|
|
||||||
return this.request<{ status: string }>('/friends/request', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ friendId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptFriendRequest(friendshipId: string) {
|
|
||||||
return this.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async declineFriendRequest(friendshipId: string) {
|
|
||||||
return this.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFriend(friendshipId: string) {
|
|
||||||
return this.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getIceServers() {
|
|
||||||
return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Klipy
|
|
||||||
async getTrendingGifs() {
|
|
||||||
return this.request<any>('/klipy/trending');
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchKlipyGifs(query: string) {
|
|
||||||
return this.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = new ApiClient();
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { httpClient } from './httpClient';
|
||||||
|
|
||||||
|
export class AppApi {
|
||||||
|
static async analyzeTelegramImport(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return httpClient.request('/import/telegram/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
|
||||||
|
return httpClient.request('/import/telegram/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getTrendingGifs() {
|
||||||
|
return httpClient.request<any>('/klipy/trending');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async searchKlipyGifs(query: string) {
|
||||||
|
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export class HttpClient {
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
setToken(token: string | null) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||||
|
const { timeout = 30_000, ...fetchOptions } = options;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
|
||||||
|
|
||||||
|
const isFormData = fetchOptions.body instanceof FormData;
|
||||||
|
const computedHeaders: Record<string, string> = {
|
||||||
|
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||||
|
...(fetchOptions.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isFormData && !computedHeaders['Content-Type']) {
|
||||||
|
computedHeaders['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: computedHeaders,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('Время ожидания запроса истекло');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = new HttpClient();
|
||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api } from '../lib/api';
|
import { AuthApi } from '../infrastructure/authApi';
|
||||||
import { connectSocket, disconnectSocket } from '../lib/socket';
|
import { connectSocket, disconnectSocket } from '../../../lib/socket';
|
||||||
import type { User } from '../lib/types';
|
import type { User } from '../../../lib/types';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -27,7 +27,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
fetchConfig: async () => {
|
fetchConfig: async () => {
|
||||||
if (!get().token) return;
|
if (!get().token) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.getConfig();
|
const res = await AuthApi.getConfig();
|
||||||
set({ config: res });
|
set({ config: res });
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
@@ -35,9 +35,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
try {
|
try {
|
||||||
set({ error: null, isLoading: true });
|
set({ error: null, isLoading: true });
|
||||||
const { token, user } = await api.login(username, password);
|
const { token, user } = await AuthApi.login(username, password);
|
||||||
localStorage.setItem('knot_token', token);
|
localStorage.setItem('knot_token', token);
|
||||||
api.setToken(token);
|
AuthApi.setToken(token);
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ token, user, isLoading: false });
|
set({ token, user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
@@ -51,9 +51,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
register: async (username, displayName, password, bio) => {
|
register: async (username, displayName, password, bio) => {
|
||||||
try {
|
try {
|
||||||
set({ error: null, isLoading: true });
|
set({ error: null, isLoading: true });
|
||||||
const { token, user } = await api.register(username, displayName, password, bio);
|
const { token, user } = await AuthApi.register(username, displayName, password, bio);
|
||||||
localStorage.setItem('knot_token', token);
|
localStorage.setItem('knot_token', token);
|
||||||
api.setToken(token);
|
AuthApi.setToken(token);
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ token, user, isLoading: false });
|
set({ token, user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
@@ -66,7 +66,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('knot_token');
|
localStorage.removeItem('knot_token');
|
||||||
api.setToken(null);
|
AuthApi.setToken(null);
|
||||||
disconnectSocket();
|
disconnectSocket();
|
||||||
set({ token: null, user: null });
|
set({ token: null, user: null });
|
||||||
},
|
},
|
||||||
@@ -82,8 +82,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
api.setToken(token);
|
AuthApi.setToken(token);
|
||||||
const { user } = await api.getMe();
|
const { user } = await AuthApi.getMe();
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ user, isLoading: false });
|
set({ user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface LoginCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterCredentials {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
password: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { httpClient } from '../../../lib/httpClient';
|
||||||
|
import type { User } from '../../../lib/types';
|
||||||
|
|
||||||
|
export class AuthApi {
|
||||||
|
static async login(username: string, password: string) {
|
||||||
|
return httpClient.request<{ token: string; user: User }>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||||
|
return httpClient.request<{ token: string; user: User }>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, displayName, password, bio }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getMe() {
|
||||||
|
return httpClient.request<{ user: User }>('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getConfig() {
|
||||||
|
return httpClient.request<any>('/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
static setToken(token: string | null) {
|
||||||
|
httpClient.setToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useLang } from '../../../lib/i18n';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
import LoginForm from './components/LoginForm';
|
||||||
|
import RegisterForm from './components/RegisterForm';
|
||||||
|
import { AuthApi } from '../infrastructure/authApi';
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const { lang, setLang } = useLang();
|
||||||
|
const [enableRegistration, setEnableRegistration] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AuthApi.getConfig()
|
||||||
|
.then(data => {
|
||||||
|
if (data && typeof data.enableRegistration === 'boolean') {
|
||||||
|
setEnableRegistration(data.enableRegistration);
|
||||||
|
if (!data.enableRegistration) setIsLogin(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
|
||||||
|
>
|
||||||
|
{/* Переключатель языка сверху по центру */}
|
||||||
|
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
|
||||||
|
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
|
||||||
|
<div className="w-px h-4 bg-white/10 self-center" />
|
||||||
|
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карточка авторизации */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
|
className="relative z-10 w-full max-w-[420px] mx-4"
|
||||||
|
>
|
||||||
|
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex flex-col items-center mb-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ rotate: -180, scale: 0 }}
|
||||||
|
animate={{ rotate: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||||
|
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
|
||||||
|
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
|
||||||
|
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={isLogin ? 'login' : 'register'}
|
||||||
|
initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{isLogin ? (
|
||||||
|
<LoginForm
|
||||||
|
enableRegistration={enableRegistration}
|
||||||
|
onRegisterClick={() => setIsLogin(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RegisterForm
|
||||||
|
enableRegistration={enableRegistration}
|
||||||
|
onLoginClick={() => setIsLogin(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState, FormEvent } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../application/authStore';
|
||||||
|
import { useLang } from '../../../../lib/i18n';
|
||||||
|
import type { LoginCredentials } from '../../domain/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onRegisterClick?: () => void;
|
||||||
|
enableRegistration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginForm({ onRegisterClick, enableRegistration }: Props) {
|
||||||
|
const [credentials, setCredentials] = useState<LoginCredentials>({ username: '', password: '' });
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const { lang } = useLang();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(credentials.username, credentials.password);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={credentials.username}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||||
|
placeholder="username"
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
{lang === 'ru' ? 'Пароль' : 'Password'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||||
|
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
||||||
|
style={{ marginTop: '32px' }}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{lang === 'ru' ? 'Войти' : 'Login'}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{enableRegistration && onRegisterClick && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
||||||
|
<p className="text-zinc-500 text-[13px] font-medium">
|
||||||
|
{lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onRegisterClick}
|
||||||
|
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{lang === 'ru' ? 'Зарегистрироваться' : 'Register'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState, FormEvent } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../application/authStore';
|
||||||
|
import { useLang } from '../../../../lib/i18n';
|
||||||
|
import type { RegisterCredentials } from '../../domain/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoginClick: () => void;
|
||||||
|
enableRegistration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterForm({ onLoginClick, enableRegistration }: Props) {
|
||||||
|
const [credentials, setCredentials] = useState<RegisterCredentials>({ username: '', displayName: '', password: '', bio: '' });
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { register } = useAuthStore();
|
||||||
|
const { lang } = useLang();
|
||||||
|
|
||||||
|
if (!enableRegistration) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(credentials.username, credentials.displayName || credentials.username, credentials.password, credentials.bio);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
Username <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={credentials.username}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||||
|
placeholder="username"
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={credentials.displayName}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
|
||||||
|
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
{lang === 'ru' ? 'Пароль' : 'Password'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||||
|
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-[#9b66ff]" />
|
||||||
|
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
{lang === 'ru' ? 'О себе' : 'About me'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={credentials.bio}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, bio: e.target.value })}
|
||||||
|
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
||||||
|
style={{ marginTop: '32px' }}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{lang === 'ru' ? 'Создать аккаунт' : 'Create account'}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{enableRegistration && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
||||||
|
<p className="text-zinc-500 text-[13px] font-medium">
|
||||||
|
{lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onLoginClick}
|
||||||
|
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{lang === 'ru' ? 'Войти' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-8
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../infrastructure/chatApi';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from '../../auth/application/authStore';
|
||||||
import type { Chat, ChatMember, Message, TypingUser } from '../lib/types';
|
import type { Chat, ChatMember, Message, TypingUser } from '../../../lib/types';
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
@@ -90,11 +90,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
loadChats: async () => {
|
loadChats: async () => {
|
||||||
try {
|
try {
|
||||||
set({ isLoadingChats: true });
|
set({ isLoadingChats: true });
|
||||||
const chats = await api.getChats();
|
const chats = await ChatApi.getChats();
|
||||||
// Auto-create favorites chat if not present
|
// Auto-create favorites chat if not present
|
||||||
if (!chats.some((c: any) => c.type === 'favorites')) {
|
if (!chats.some((c: any) => c.type === 'favorites')) {
|
||||||
try {
|
try {
|
||||||
const favChat = await api.getOrCreateFavorites();
|
const favChat = await ChatApi.getOrCreateFavorites();
|
||||||
chats.unshift(favChat);
|
chats.unshift(favChat);
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Load chats error:', error);
|
console.error('Load chats error:', error);
|
||||||
set({ isLoadingChats: false });
|
set({ isLoadingChats: false });
|
||||||
const { addNotification } = (await import('./notificationStore')).useNotificationStore.getState();
|
const { addNotification } = (await import('../../../stores/notificationStore')).useNotificationStore.getState();
|
||||||
addNotification('error', error.message || 'Failed to load chats');
|
addNotification('error', error.message || 'Failed to load chats');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const currentMessages = state.messages[chatId] || [];
|
const currentMessages = state.messages[chatId] || [];
|
||||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
|
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
|
||||||
|
|
||||||
const fetched = await api.getMessages(chatId, cursor);
|
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Merge fetched messages with any that arrived via socket
|
// Merge fetched messages with any that arrived via socket
|
||||||
@@ -144,7 +144,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Load messages error:', error);
|
console.error('Load messages error:', error);
|
||||||
set({ isLoadingMessages: false });
|
set({ isLoadingMessages: false });
|
||||||
const { addNotification } = (await import('./notificationStore')).useNotificationStore.getState();
|
const { addNotification } = (await import('../../../stores/notificationStore')).useNotificationStore.getState();
|
||||||
addNotification('error', error.message || 'Failed to load messages');
|
addNotification('error', error.message || 'Failed to load messages');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { httpClient } from '../../../lib/httpClient';
|
||||||
|
import type { Chat, Message } from '../../../lib/types';
|
||||||
|
|
||||||
|
export class ChatApi {
|
||||||
|
static async getChats() {
|
||||||
|
return httpClient.request<Chat[]>('/chats');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createPersonalChat(userId: string) {
|
||||||
|
return httpClient.request<Chat>('/chats/personal', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createGroupChat(name: string, memberIds: string[]) {
|
||||||
|
return httpClient.request<Chat>('/chats/group', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, memberIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getMessages(chatId: string, cursor?: string) {
|
||||||
|
const params = cursor ? `?cursor=${cursor}` : '';
|
||||||
|
return httpClient.request<Message[]>(`/messages/chat/${chatId}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadFile(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return httpClient.request<{ url: string; filename: string; size: number }>('/messages/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateGroup(chatId: string, data: { name?: string; description?: string }) {
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadGroupAvatar(chatId: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static 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());
|
||||||
|
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}/avatar/crop`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeGroupAvatar(chatId: string) {
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addGroupMembers(chatId: string, userIds: string[]) {
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeGroupMember(chatId: string, userId: string) {
|
||||||
|
return httpClient.request<Chat>(`/chats/${chatId}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async clearChat(chatId: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteChat(chatId: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async togglePinChat(chatId: string) {
|
||||||
|
return httpClient.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async searchMessages(query: string, chatId?: string) {
|
||||||
|
const params = new URLSearchParams({ q: query });
|
||||||
|
if (chatId) params.append('chatId', chatId);
|
||||||
|
return httpClient.request<Message[]>(`/messages/search?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
|
||||||
|
return httpClient.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getOrCreateFavorites() {
|
||||||
|
return httpClient.request<Chat>('/chats/favorites', { method: 'POST' });
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-13
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../application/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../auth/application/authStore';
|
||||||
import { getSocket, disconnectSocket } from '../lib/socket';
|
import { getSocket, disconnectSocket } from '../../../lib/socket';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../infrastructure/chatApi';
|
||||||
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../lib/sounds';
|
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../lib/sounds';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../../../lib/i18n';
|
||||||
import type { Message, UserBasic, CallInfo } from '../lib/types';
|
import type { Message, UserBasic, CallInfo } from '../../../lib/types';
|
||||||
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
|
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../../../components/Sidebar';
|
||||||
import ChatView from '../components/ChatView';
|
import ChatView from './components/ChatView';
|
||||||
import CallModal from '../components/CallModal';
|
import CallModal from '../../../components/CallModal';
|
||||||
import GroupCallModal from '../components/GroupCallModal';
|
import GroupCallModal from '../../../components/GroupCallModal';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const {
|
const {
|
||||||
@@ -94,7 +94,7 @@ export default function ChatPage() {
|
|||||||
const { chats } = useChatStore.getState();
|
const { chats } = useChatStore.getState();
|
||||||
if (!chats.some(c => c.id === message.chatId)) {
|
if (!chats.some(c => c.id === message.chatId)) {
|
||||||
try {
|
try {
|
||||||
const allChats = await api.getChats();
|
const allChats = await ChatApi.getChats();
|
||||||
const newChat = allChats.find(c => c.id === message.chatId);
|
const newChat = allChats.find(c => c.id === message.chatId);
|
||||||
if (newChat) {
|
if (newChat) {
|
||||||
// Reset unreadCount to 0 because addMessage below will increment it by 1
|
// Reset unreadCount to 0 because addMessage below will increment it by 1
|
||||||
@@ -116,7 +116,7 @@ export default function ChatPage() {
|
|||||||
const { chats } = useChatStore.getState();
|
const { chats } = useChatStore.getState();
|
||||||
if (!chats.some(c => c.id === message.chatId)) {
|
if (!chats.some(c => c.id === message.chatId)) {
|
||||||
try {
|
try {
|
||||||
const allChats = await api.getChats();
|
const allChats = await ChatApi.getChats();
|
||||||
const newChat = allChats.find(c => c.id === message.chatId);
|
const newChat = allChats.find(c => c.id === message.chatId);
|
||||||
if (newChat) useChatStore.getState().addChat(newChat);
|
if (newChat) useChatStore.getState().addChat(newChat);
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) { /* ignore */ }
|
||||||
+10
-10
@@ -2,14 +2,14 @@ import { useState, useRef, useEffect, memo } from 'react';
|
|||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ru, enUS } from 'date-fns/locale';
|
import { ru, enUS } from 'date-fns/locale';
|
||||||
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
|
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import { stripMarkdown } from '../lib/utils';
|
import { stripMarkdown } from '../../../../lib/utils';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../../infrastructure/chatApi';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from '../../../../components/ConfirmModal';
|
||||||
import Avatar from './Avatar';
|
import Avatar from '../../../../components/Avatar';
|
||||||
import type { Chat } from '../lib/types';
|
import type { Chat } from '../../../../lib/types';
|
||||||
|
|
||||||
interface ChatListItemProps {
|
interface ChatListItemProps {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
@@ -98,7 +98,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const handlePin = async () => {
|
const handlePin = async () => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
try {
|
try {
|
||||||
await api.togglePinChat(chat.id);
|
await ChatApi.togglePinChat(chat.id);
|
||||||
loadChats();
|
loadChats();
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
};
|
};
|
||||||
@@ -111,7 +111,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
try {
|
try {
|
||||||
await api.deleteChat(chat.id);
|
await ChatApi.deleteChat(chat.id);
|
||||||
useChatStore.getState().removeChat(chat.id);
|
useChatStore.getState().removeChat(chat.id);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
};
|
};
|
||||||
+17
-17
@@ -19,23 +19,23 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../../infrastructure/chatApi';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../../../../lib/socket';
|
||||||
import { isChatMuted, toggleMuteChat } from '../lib/sounds';
|
import { isChatMuted, toggleMuteChat } from '../../../../lib/sounds';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import { formatLastSeen } from '../lib/utils';
|
import { formatLastSeen } from '../../../../lib/utils';
|
||||||
import type { UserBasic, Message } from '../lib/types';
|
import type { UserBasic, Message } from '../../../../lib/types';
|
||||||
import MessageBubble from './MessageBubble';
|
import MessageBubble from './MessageBubble';
|
||||||
import MessageInput from './MessageInput';
|
import MessageInput from './MessageInput';
|
||||||
import TypingIndicator from './TypingIndicator';
|
import TypingIndicator from '../../../../components/TypingIndicator';
|
||||||
import UserProfile from './UserProfile';
|
import UserProfile from '../../../users/presentation/components/UserProfile';
|
||||||
import GroupSettings from './GroupSettings';
|
import GroupSettings from './GroupSettings';
|
||||||
import ForwardModal from './ForwardModal';
|
import ForwardModal from '../../../../components/ForwardModal';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from '../../../../components/ConfirmModal';
|
||||||
import Avatar from './Avatar';
|
import Avatar from '../../../../components/Avatar';
|
||||||
import { useThemeStore } from '../stores/themeStore';
|
import { useThemeStore } from '../../../../stores/themeStore';
|
||||||
|
|
||||||
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
|
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
|
||||||
const { user, config } = useAuthStore();
|
const { user, config } = useAuthStore();
|
||||||
@@ -353,7 +353,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}
|
}
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const results = await api.searchMessages(searchText, activeChat);
|
const results = await ChatApi.searchMessages(searchText, activeChat);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -722,7 +722,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
message: t('clearChatConfirm'),
|
message: t('clearChatConfirm'),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api.clearChat(activeChat);
|
await ChatApi.clearChat(activeChat);
|
||||||
useChatStore.getState().clearMessages(activeChat);
|
useChatStore.getState().clearMessages(activeChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -744,7 +744,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
message: t('deleteChatConfirm'),
|
message: t('deleteChatConfirm'),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await api.deleteChat(activeChat);
|
await ChatApi.deleteChat(activeChat);
|
||||||
useChatStore.getState().removeChat(activeChat);
|
useChatStore.getState().removeChat(activeChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
+21
-20
@@ -20,16 +20,17 @@ import {
|
|||||||
Video
|
Video
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Cropper from 'react-easy-crop';
|
import Cropper from 'react-easy-crop';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../../infrastructure/chatApi';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { Chat, UserPresence, Message } from '../lib/types';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import Avatar from './Avatar';
|
import { Chat, UserPresence, Message } from '../../../../lib/types';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import Avatar from '../../../../components/Avatar';
|
||||||
import ImageLightbox from './ImageLightbox';
|
import ConfirmModal from '../../../../components/ConfirmModal';
|
||||||
import { getMediaUrl } from '../lib/utils';
|
import ImageLightbox from '../../../../components/ImageLightbox';
|
||||||
import { getCroppedImg } from '../lib/imageCrop';
|
import { getMediaUrl } from '../../../../lib/utils';
|
||||||
|
import { getCroppedImg } from '../../../../lib/imageCrop';
|
||||||
|
|
||||||
interface GroupSettingsProps {
|
interface GroupSettingsProps {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
@@ -91,7 +92,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await api.searchUsers(searchQuery);
|
const results = await UserApi.searchUsers(searchQuery);
|
||||||
// Filter out users already in the group
|
// Filter out users already in the group
|
||||||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||||||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
||||||
@@ -108,7 +109,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
if (!groupName.trim()) return;
|
if (!groupName.trim()) return;
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const updatedChat = await api.updateGroup(chat.id, { name: groupName.trim() });
|
const updatedChat = await ChatApi.updateGroup(chat.id, { name: groupName.trim() });
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -121,7 +122,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const handleSaveDesc = async () => {
|
const handleSaveDesc = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const updatedChat = await api.updateGroup(chat.id, { description: groupDesc.trim() });
|
const updatedChat = await ChatApi.updateGroup(chat.id, { description: groupDesc.trim() });
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
setIsEditingDesc(false);
|
setIsEditingDesc(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -151,7 +152,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||||
if (!croppedFile) throw new Error("Could not crop image");
|
if (!croppedFile) throw new Error("Could not crop image");
|
||||||
|
|
||||||
const updatedChat = await api.uploadGroupAvatar(chat.id, croppedFile);
|
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, croppedFile);
|
||||||
|
|
||||||
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
||||||
setIsCropping(false);
|
setIsCropping(false);
|
||||||
@@ -170,7 +171,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
setAvatarUploading(true);
|
setAvatarUploading(true);
|
||||||
const updatedChat = await api.uploadGroupAvatar(chat.id, file);
|
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, file);
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -183,7 +184,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const handleRemoveAvatar = async () => {
|
const handleRemoveAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
setAvatarUploading(true);
|
setAvatarUploading(true);
|
||||||
const updatedChat = await api.removeGroupAvatar(chat.id);
|
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -194,7 +195,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
|
|
||||||
const handleAddMember = async (userId: string) => {
|
const handleAddMember = async (userId: string) => {
|
||||||
try {
|
try {
|
||||||
const updatedChat = await api.addGroupMembers(chat.id, [userId]);
|
const updatedChat = await ChatApi.addGroupMembers(chat.id, [userId]);
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -210,7 +211,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
const confirmRemoveMember = async () => {
|
const confirmRemoveMember = async () => {
|
||||||
if (!removeTargetId) return;
|
if (!removeTargetId) return;
|
||||||
try {
|
try {
|
||||||
const updatedChat = await api.removeGroupMember(chat.id, removeTargetId);
|
const updatedChat = await ChatApi.removeGroupMember(chat.id, removeTargetId);
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -229,7 +230,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
if (loadedTabs.has(tab)) return;
|
if (loadedTabs.has(tab)) return;
|
||||||
setTabLoading(true);
|
setTabLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getSharedMedia(chat.id, tab);
|
const data = await ChatApi.getSharedMedia(chat.id, tab);
|
||||||
if (tab === 'media') setSharedMedia(data);
|
if (tab === 'media') setSharedMedia(data);
|
||||||
else if (tab === 'gifs') setSharedGifs(data);
|
else if (tab === 'gifs') setSharedGifs(data);
|
||||||
else if (tab === 'files') setSharedFiles(data);
|
else if (tab === 'files') setSharedFiles(data);
|
||||||
@@ -385,7 +386,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
const updatedChat = await api.removeGroupAvatar(chat.id);
|
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
|
||||||
updateChat(updatedChat);
|
updateChat(updatedChat);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to remove avatar', e);
|
console.error('Failed to remove avatar', e);
|
||||||
+8
-8
@@ -20,14 +20,14 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Forward,
|
Forward,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../../../../lib/socket';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import { extractWaveform, getMediaUrl } from '../lib/utils';
|
import { extractWaveform, getMediaUrl } from '../../../../lib/utils';
|
||||||
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../lib/types';
|
||||||
import ImageLightbox from './ImageLightbox';
|
import ImageLightbox from '../../../../components/ImageLightbox';
|
||||||
import LinkPreview from './LinkPreview';
|
import LinkPreview from '../../../../components/LinkPreview';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
+10
-10
@@ -17,14 +17,14 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../../infrastructure/chatApi';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../../../../lib/socket';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE } from '../lib/types';
|
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE } from '../../../../lib/types';
|
||||||
import { useNotificationStore } from '../stores/notificationStore';
|
import { useNotificationStore } from '../../../../stores/notificationStore';
|
||||||
import EmojiPicker from './EmojiPicker';
|
import EmojiPicker from '../../../../components/EmojiPicker';
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -203,7 +203,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
if (hasAttachments) {
|
if (hasAttachments) {
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
const uploadPromises = attachments.map(a => api.uploadFile(a.file));
|
const uploadPromises = attachments.map(a => ChatApi.uploadFile(a.file));
|
||||||
const results = await Promise.all(uploadPromises);
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
const socketAttachments = results.map((res, i) => ({
|
const socketAttachments = results.map((res, i) => ({
|
||||||
@@ -412,7 +412,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
const file = new File([blob], `voice.${ext}`, { type: mimeType });
|
const file = new File([blob], `voice.${ext}`, { type: mimeType });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.uploadFile(file);
|
const result = await ChatApi.uploadFile(file);
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit('send_message', {
|
socket.emit('send_message', {
|
||||||
+13
-11
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
|
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import { api } from '../lib/api';
|
import { ChatApi } from '../../infrastructure/chatApi';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { FriendApi } from '../../../friends/infrastructure/friendApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import type { UserPresence, FriendWithId } from '../lib/types';
|
import { useAuthStore } from '../../../../modules/auth/application/authStore';
|
||||||
|
import { useLang } from '../../../../lib/i18n';
|
||||||
|
import type { UserPresence, FriendWithId } from '../../../../lib/types';
|
||||||
|
|
||||||
interface NewChatModalProps {
|
interface NewChatModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,7 +30,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
|||||||
|
|
||||||
// Load friends on mount
|
// Load friends on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getFriends().then(setFriends).catch(() => {});
|
FriendApi.getFriends().then(setFriends).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,7 +41,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const results = await api.searchUsers(query);
|
const results = await UserApi.searchUsers(query);
|
||||||
setUsers(results.filter((u) => u.id !== user?.id));
|
setUsers(results.filter((u) => u.id !== user?.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -53,9 +55,9 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
|||||||
const handleSelectUser = async (selectedUser: UserPresence) => {
|
const handleSelectUser = async (selectedUser: UserPresence) => {
|
||||||
if (mode === 'personal') {
|
if (mode === 'personal') {
|
||||||
try {
|
try {
|
||||||
const chat = await api.createPersonalChat(selectedUser.id);
|
const chat = await ChatApi.createPersonalChat(selectedUser.id);
|
||||||
addChat(chat);
|
addChat(chat);
|
||||||
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
import('../../../../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||||
setActiveChat(chat.id);
|
setActiveChat(chat.id);
|
||||||
loadMessages(chat.id);
|
loadMessages(chat.id);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -76,12 +78,12 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
|||||||
if (!groupName.trim() || selectedUsers.length === 0) return;
|
if (!groupName.trim() || selectedUsers.length === 0) return;
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const chat = await api.createGroupChat(
|
const chat = await ChatApi.createGroupChat(
|
||||||
groupName.trim(),
|
groupName.trim(),
|
||||||
selectedUsers.map((u) => u.id)
|
selectedUsers.map((u) => u.id)
|
||||||
);
|
);
|
||||||
addChat(chat);
|
addChat(chat);
|
||||||
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
import('../../../../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||||
setActiveChat(chat.id);
|
setActiveChat(chat.id);
|
||||||
loadMessages(chat.id);
|
loadMessages(chat.id);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { UserApi } from '../../users/infrastructure/userApi';
|
||||||
|
import { FriendApi } from '../infrastructure/friendApi';
|
||||||
|
import type { FriendWithId, FriendRequest, UserPresence } from '../../../lib/types';
|
||||||
|
import { getSocket } from '../../../lib/socket';
|
||||||
|
|
||||||
|
interface FriendState {
|
||||||
|
friends: FriendWithId[];
|
||||||
|
friendRequests: FriendRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
searchResults: UserPresence[];
|
||||||
|
isSearching: boolean;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
loadFriends: () => Promise<void>;
|
||||||
|
acceptRequest: (requestId: string) => Promise<void>;
|
||||||
|
declineRequest: (requestId: string) => Promise<void>;
|
||||||
|
removeFriend: (friendshipId: string) => Promise<void>;
|
||||||
|
sendRequest: (friendId: string) => Promise<void>;
|
||||||
|
searchFriends: (query: string, currentUserId?: string) => Promise<void>;
|
||||||
|
clearSearch: () => void;
|
||||||
|
initializeSocketEvents: () => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFriendStore = create<FriendState>((set, get) => ({
|
||||||
|
friends: [],
|
||||||
|
friendRequests: [],
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: '',
|
||||||
|
searchResults: [],
|
||||||
|
isSearching: false,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
|
||||||
|
loadFriends: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const [friendsList, requests] = await Promise.all([
|
||||||
|
FriendApi.getFriends(),
|
||||||
|
FriendApi.getFriendRequests(),
|
||||||
|
]);
|
||||||
|
set({ friends: friendsList, friendRequests: requests });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Load friends error:', e);
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptRequest: async (requestId) => {
|
||||||
|
try {
|
||||||
|
await FriendApi.acceptFriendRequest(requestId);
|
||||||
|
const req = get().friendRequests.find(r => r.id === requestId);
|
||||||
|
if (req) {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) socket.emit('friend_accepted', { friendId: req.user.id });
|
||||||
|
}
|
||||||
|
await get().loadFriends();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
declineRequest: async (requestId) => {
|
||||||
|
try {
|
||||||
|
await FriendApi.declineFriendRequest(requestId);
|
||||||
|
set((state) => ({
|
||||||
|
friendRequests: state.friendRequests.filter(r => r.id !== requestId)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFriend: async (friendshipId) => {
|
||||||
|
try {
|
||||||
|
const friend = get().friends.find(f => f.friendshipId === friendshipId);
|
||||||
|
await FriendApi.removeFriend(friendshipId);
|
||||||
|
if (friend) {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) socket.emit('friend_removed', { friendId: friend.id });
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
friends: state.friends.filter(f => f.friendshipId !== friendshipId)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendRequest: async (friendId) => {
|
||||||
|
try {
|
||||||
|
const result = await FriendApi.sendFriendRequest(friendId);
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) socket.emit('friend_request', { friendId });
|
||||||
|
|
||||||
|
if (result.status === 'accepted') {
|
||||||
|
await get().loadFriends();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
searchResults: state.searchResults.filter(u => u.id !== friendId)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
searchFriends: async (query, currentUserId) => {
|
||||||
|
const raw = query.trim();
|
||||||
|
const q = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||||
|
|
||||||
|
if (q.length < 3) {
|
||||||
|
set({ searchResults: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSearching: true });
|
||||||
|
try {
|
||||||
|
const results = await UserApi.searchUsers(q);
|
||||||
|
const { friends } = get();
|
||||||
|
const friendIds = new Set(friends.map(f => f.id));
|
||||||
|
|
||||||
|
set({
|
||||||
|
searchResults: results.filter(u => u.id !== currentUserId && !friendIds.has(u.id))
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
set({ isSearching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSearch: () => set({ searchQuery: '', searchResults: [] }),
|
||||||
|
|
||||||
|
initializeSocketEvents: () => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket) return () => {};
|
||||||
|
|
||||||
|
const onFriendRequestReceived = () => {
|
||||||
|
FriendApi.getFriendRequests()
|
||||||
|
.then(reqs => set({ friendRequests: reqs }))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFriendRequestAccepted = () => {
|
||||||
|
get().loadFriends();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFriendRemoved = (data: { userId: string }) => {
|
||||||
|
set((state) => ({
|
||||||
|
friends: state.friends.filter(f => f.id !== data.userId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('friend_request_received', onFriendRequestReceived);
|
||||||
|
socket.on('friend_request_accepted', onFriendRequestAccepted);
|
||||||
|
socket.on('friend_removed', onFriendRemoved);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('friend_request_received', onFriendRequestReceived);
|
||||||
|
socket.off('friend_request_accepted', onFriendRequestAccepted);
|
||||||
|
socket.off('friend_removed', onFriendRemoved);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { httpClient } from '../../../lib/httpClient';
|
||||||
|
import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../lib/types';
|
||||||
|
|
||||||
|
export class FriendApi {
|
||||||
|
static async getFriends() {
|
||||||
|
return httpClient.request<FriendWithId[]>('/friends');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFriendRequests() {
|
||||||
|
return httpClient.request<FriendRequest[]>('/friends/requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getOutgoingRequests() {
|
||||||
|
return httpClient.request<FriendRequest[]>('/friends/outgoing');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFriendshipStatus(userId: string) {
|
||||||
|
return httpClient.request<FriendshipStatus>(`/friends/status/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sendFriendRequest(friendId: string) {
|
||||||
|
return httpClient.request<{ status: string }>('/friends/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ friendId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async acceptFriendRequest(friendshipId: string) {
|
||||||
|
return httpClient.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async declineFriendRequest(friendshipId: string) {
|
||||||
|
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeFriend(friendshipId: string) {
|
||||||
|
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { httpClient } from '../../../lib/httpClient';
|
||||||
|
import type { StoryGroup } from '../../../lib/types';
|
||||||
|
|
||||||
|
export class StoryApi {
|
||||||
|
static async getStories() {
|
||||||
|
return httpClient.request<StoryGroup[]>('/stories');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserStories(userId: string) {
|
||||||
|
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
||||||
|
return httpClient.request<{ id: string }>('/stories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadVideoToStory(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return httpClient.request<{ url: string }>('/stories/video', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async viewStory(storyId: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteStory(storyId: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStoryViewers(storyId: string) {
|
||||||
|
return httpClient.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addStoryReaction(storyId: string, emoji: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ emoji }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeStoryReaction(storyId: string, emoji: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ emoji }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addStoryReply(storyId: string, content: string) {
|
||||||
|
return httpClient.request<{ message: string }>(`/stories/${storyId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStoryReplies(storyId: string) {
|
||||||
|
return httpClient.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-14
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import { api } from '../lib/api';
|
import { StoryApi } from '../../infrastructure/storyApi';
|
||||||
import { getSocket } from '../lib/socket';
|
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { getSocket } from '../../../../lib/socket';
|
||||||
import Avatar from './Avatar';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import { StoryGroup } from '../lib/types';
|
import Avatar from '../../../../components/Avatar';
|
||||||
import { getMediaUrl } from '../lib/utils';
|
import { StoryGroup } from '../../../../lib/types';
|
||||||
|
import { getMediaUrl } from '../../../../lib/utils';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
const storyId = currentStory.id;
|
const storyId = currentStory.id;
|
||||||
const viewCount = currentStory.viewCount || 0;
|
const viewCount = currentStory.viewCount || 0;
|
||||||
|
|
||||||
api.viewStory(storyId).then(() => {
|
StoryApi.viewStory(storyId).then(() => {
|
||||||
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
|
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
|
||||||
setViewOverrides(prev => ({
|
setViewOverrides(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -265,7 +266,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
if (!currentStory) return;
|
if (!currentStory) return;
|
||||||
const storyId = currentStory.id;
|
const storyId = currentStory.id;
|
||||||
try {
|
try {
|
||||||
await api.deleteStory(storyId);
|
await StoryApi.deleteStory(storyId);
|
||||||
|
|
||||||
if (currentUser.stories.length > 1) {
|
if (currentUser.stories.length > 1) {
|
||||||
if (storyIndex >= currentUser.stories.length - 1) {
|
if (storyIndex >= currentUser.stories.length - 1) {
|
||||||
@@ -308,7 +309,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.addStoryReaction(currentStory.id, emoji);
|
await StoryApi.addStoryReaction(currentStory.id, emoji);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Add reaction error:', e);
|
console.error('Add reaction error:', e);
|
||||||
}
|
}
|
||||||
@@ -319,7 +320,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
setSendingReply(true);
|
setSendingReply(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.addStoryReply(currentStory.id, replyText.trim());
|
await StoryApi.addStoryReply(currentStory.id, replyText.trim());
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
setShowReplyInput(false);
|
setShowReplyInput(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -430,7 +431,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
setPaused(true);
|
setPaused(true);
|
||||||
setShowViewers(true);
|
setShowViewers(true);
|
||||||
setViewersLoading(true);
|
setViewersLoading(true);
|
||||||
api.getStoryViewers(currentStory.id).then(v => {
|
StoryApi.getStoryViewers(currentStory.id).then(v => {
|
||||||
setViewers(v);
|
setViewers(v);
|
||||||
setViewersLoading(false);
|
setViewersLoading(false);
|
||||||
}).catch(() => setViewersLoading(false));
|
}).catch(() => setViewersLoading(false));
|
||||||
@@ -659,11 +660,11 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
try {
|
try {
|
||||||
let mediaUrl: string | undefined;
|
let mediaUrl: string | undefined;
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
const result = await api.uploadFile(imageFile);
|
const result = await ChatApi.uploadFile(imageFile);
|
||||||
mediaUrl = result.url;
|
mediaUrl = result.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.createStory({
|
await StoryApi.createStory({
|
||||||
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
|
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
|
||||||
content: mode === 'text' ? text.trim() : undefined,
|
content: mode === 'text' ? text.trim() : undefined,
|
||||||
bgColor: mode === 'text' ? bgColor : undefined,
|
bgColor: mode === 'text' ? bgColor : undefined,
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { httpClient } from '../../../lib/httpClient';
|
||||||
|
import type { User, UserPresence } from '../../../lib/types';
|
||||||
|
|
||||||
|
export class UserApi {
|
||||||
|
static async searchUsers(query: string) {
|
||||||
|
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUser(id: string) {
|
||||||
|
return httpClient.request<User>(`/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
|
||||||
|
return httpClient.request<User>('/users/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateSettings(settings: any) {
|
||||||
|
return httpClient.request('/users/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadAvatar(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
return httpClient.request<User>('/users/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
formData.append('cropX', cropData.x.toString());
|
||||||
|
formData.append('cropY', cropData.y.toString());
|
||||||
|
formData.append('cropWidth', cropData.width.toString());
|
||||||
|
formData.append('cropHeight', cropData.height.toString());
|
||||||
|
|
||||||
|
return httpClient.request<User>('/users/avatar/crop', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeAvatar() {
|
||||||
|
return httpClient.request<User>('/users/avatar', { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getIceServers() {
|
||||||
|
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-25
@@ -2,20 +2,23 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react';
|
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react';
|
||||||
import Cropper from 'react-easy-crop';
|
import Cropper from 'react-easy-crop';
|
||||||
import { api } from '../lib/api';
|
import { UserApi } from '../../infrastructure/userApi';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||||
import { useLang } from '../lib/i18n';
|
import { FriendApi } from '../../../friends/infrastructure/friendApi';
|
||||||
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
|
import { StoryApi } from '../../../stories/infrastructure/storyApi';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import ImageLightbox from './ImageLightbox';
|
import { useLang } from '../../../../lib/i18n';
|
||||||
import StoryViewer from './StoryViewer';
|
import { User, Message, FriendshipStatus, StoryGroup } from '../../../../lib/types';
|
||||||
import { getSocket } from '../lib/socket';
|
import ConfirmModal from '../../../../components/ConfirmModal';
|
||||||
import { useStoryStore } from '../stores/useStoryStore';
|
import ImageLightbox from '../../../../components/ImageLightbox';
|
||||||
import { getMediaUrl } from '../lib/utils';
|
import StoryViewer from '../../../../modules/stories/presentation/components/StoryViewer';
|
||||||
import { getCroppedImg } from '../lib/imageCrop';
|
import { getSocket } from '../../../../lib/socket';
|
||||||
import DatePicker from './DatePicker';
|
import { useStoryStore } from '../../../../modules/stories/application/storyStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { getMediaUrl } from '../../../../lib/utils';
|
||||||
import { toggleMuteChat, isChatMuted } from '../lib/sounds';
|
import { getCroppedImg } from '../../../../lib/imageCrop';
|
||||||
|
import DatePicker from '../../../../components/DatePicker';
|
||||||
|
import { useChatStore } from '../../../chats/application/chatStore';
|
||||||
|
import { toggleMuteChat, isChatMuted } from '../../../../lib/sounds';
|
||||||
|
|
||||||
interface UserProfileProps {
|
interface UserProfileProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -49,7 +52,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
try {
|
try {
|
||||||
let targetChatId = personalChat?.id;
|
let targetChatId = personalChat?.id;
|
||||||
if (!targetChatId) {
|
if (!targetChatId) {
|
||||||
const chat = await api.createPersonalChat(userId);
|
const chat = await ChatApi.createPersonalChat(userId);
|
||||||
addChat(chat);
|
addChat(chat);
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) socket.emit('join_chat', chat.id);
|
if (socket) socket.emit('join_chat', chat.id);
|
||||||
@@ -167,7 +170,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
FriendApi.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [userId, isSelf]);
|
}, [userId, isSelf]);
|
||||||
|
|
||||||
@@ -177,10 +180,10 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
setTabLoading(true);
|
setTabLoading(true);
|
||||||
try {
|
try {
|
||||||
if (tab === 'publications') {
|
if (tab === 'publications') {
|
||||||
const data = await api.getUserStories(userId);
|
const data = await StoryApi.getUserStories(userId);
|
||||||
setUserStories(data.stories || []);
|
setUserStories(data.stories || []);
|
||||||
} else if (chatId) { // Only load media/files/links if chatId is available
|
} else if (chatId) { // Only load media/files/links if chatId is available
|
||||||
const data = await api.getSharedMedia(chatId, tab);
|
const data = await ChatApi.getSharedMedia(chatId, tab);
|
||||||
if (tab === 'media') setSharedMedia(data);
|
if (tab === 'media') setSharedMedia(data);
|
||||||
else if (tab === 'gifs') setSharedGifs(data);
|
else if (tab === 'gifs') setSharedGifs(data);
|
||||||
else if (tab === 'files') setSharedFiles(data);
|
else if (tab === 'files') setSharedFiles(data);
|
||||||
@@ -211,7 +214,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
setBio(authUser.bio || '');
|
setBio(authUser.bio || '');
|
||||||
setBirthday(authUser.birthday || '');
|
setBirthday(authUser.birthday || '');
|
||||||
} else {
|
} else {
|
||||||
const data = await api.getUser(userId);
|
const data = await UserApi.getUser(userId);
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
if (isSelf) {
|
if (isSelf) {
|
||||||
setDisplayName(data.displayName || '');
|
setDisplayName(data.displayName || '');
|
||||||
@@ -230,7 +233,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const dateToSave = birthday ? new Date(birthday).toISOString() : undefined;
|
const dateToSave = birthday ? new Date(birthday).toISOString() : undefined;
|
||||||
const updated = await api.updateProfile({
|
const updated = await UserApi.updateProfile({
|
||||||
displayName: displayName.trim(),
|
displayName: displayName.trim(),
|
||||||
bio: bio.trim(),
|
bio: bio.trim(),
|
||||||
birthday: dateToSave,
|
birthday: dateToSave,
|
||||||
@@ -248,7 +251,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
const handleSendFriendRequest = async () => {
|
const handleSendFriendRequest = async () => {
|
||||||
try {
|
try {
|
||||||
setFriendLoading(true);
|
setFriendLoading(true);
|
||||||
const result = await api.sendFriendRequest(userId);
|
const result = await FriendApi.sendFriendRequest(userId);
|
||||||
if (result.status === 'accepted') {
|
if (result.status === 'accepted') {
|
||||||
setFriendStatus({ status: 'accepted', friendshipId: null });
|
setFriendStatus({ status: 'accepted', friendshipId: null });
|
||||||
} else {
|
} else {
|
||||||
@@ -268,7 +271,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
if (!friendStatus?.friendshipId) return;
|
if (!friendStatus?.friendshipId) return;
|
||||||
try {
|
try {
|
||||||
setFriendLoading(true);
|
setFriendLoading(true);
|
||||||
await api.acceptFriendRequest(friendStatus.friendshipId);
|
await FriendApi.acceptFriendRequest(friendStatus.friendshipId);
|
||||||
setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId });
|
setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId });
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) socket.emit('friend_accepted', { friendId: userId });
|
if (socket) socket.emit('friend_accepted', { friendId: userId });
|
||||||
@@ -283,7 +286,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
if (!friendStatus?.friendshipId) return;
|
if (!friendStatus?.friendshipId) return;
|
||||||
try {
|
try {
|
||||||
setFriendLoading(true);
|
setFriendLoading(true);
|
||||||
await api.removeFriend(friendStatus.friendshipId);
|
await FriendApi.removeFriend(friendStatus.friendshipId);
|
||||||
setFriendStatus({ status: 'none', friendshipId: null });
|
setFriendStatus({ status: 'none', friendshipId: null });
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) socket.emit('friend_removed', { friendId: userId });
|
if (socket) socket.emit('friend_removed', { friendId: userId });
|
||||||
@@ -313,7 +316,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||||
if (!croppedFile) throw new Error("Could not crop image");
|
if (!croppedFile) throw new Error("Could not crop image");
|
||||||
|
|
||||||
const updatedUser = await api.uploadAvatar(croppedFile);
|
const updatedUser = await UserApi.uploadAvatar(croppedFile);
|
||||||
setProfile(updatedUser);
|
setProfile(updatedUser);
|
||||||
useAuthStore.getState().updateUser(updatedUser);
|
useAuthStore.getState().updateUser(updatedUser);
|
||||||
setIsCropping(false);
|
setIsCropping(false);
|
||||||
@@ -332,7 +335,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
const handleRemoveAvatar = async () => {
|
const handleRemoveAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
setTabLoading(true);
|
setTabLoading(true);
|
||||||
await api.removeAvatar();
|
await UserApi.removeAvatar();
|
||||||
const updatedUser = { ...profile!, avatar: null };
|
const updatedUser = { ...profile!, avatar: null };
|
||||||
setProfile(updatedUser);
|
setProfile(updatedUser);
|
||||||
useAuthStore.getState().updateUser({ avatar: null });
|
useAuthStore.getState().updateUser({ avatar: null });
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import { useState, FormEvent, useEffect } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { useAuthStore } from '../stores/authStore';
|
|
||||||
import { useLang } from '../lib/i18n';
|
|
||||||
import { Eye, EyeOff, ArrowRight, UserPlus, LogIn, MessageSquare } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function AuthPage() {
|
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [bio, setBio] = useState('');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const { login, register } = useAuthStore();
|
|
||||||
const { t, lang, setLang } = useLang();
|
|
||||||
const [enableRegistration, setEnableRegistration] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/config')
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data && typeof data.enableRegistration === 'boolean') {
|
|
||||||
setEnableRegistration(data.enableRegistration);
|
|
||||||
if (!data.enableRegistration) setIsLogin(true);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isLogin) {
|
|
||||||
await login(username, password);
|
|
||||||
} else {
|
|
||||||
await register(username, displayName || username, password, bio);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
|
|
||||||
>
|
|
||||||
{/* Переключатель языка сверху по центру */}
|
|
||||||
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
|
|
||||||
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
|
|
||||||
<div className="w-px h-4 bg-white/10 self-center" />
|
|
||||||
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Карточка авторизации */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, y: 20 }}
|
|
||||||
animate={{ scale: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
|
||||||
className="relative z-10 w-full max-w-[420px] mx-4"
|
|
||||||
>
|
|
||||||
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
|
|
||||||
|
|
||||||
{/* Заголовок */}
|
|
||||||
<div className="flex flex-col items-center mb-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ rotate: -180, scale: 0 }}
|
|
||||||
animate={{ rotate: 0, scale: 1 }}
|
|
||||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
|
||||||
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
|
|
||||||
</motion.div>
|
|
||||||
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
|
|
||||||
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
|
|
||||||
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ошибка */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Форма */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
|
||||||
Username {!isLogin && <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
|
||||||
placeholder="username"
|
|
||||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{!isLogin && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div className="pt-2">
|
|
||||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
|
||||||
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
|
|
||||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className={!isLogin ? "pt-2" : ""}>
|
|
||||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
|
||||||
{lang === 'ru' ? 'Пароль' : 'Password'}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
|
||||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-hidden text-[15px]"
|
|
||||||
required
|
|
||||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!isLogin && (
|
|
||||||
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
|
|
||||||
<div className="w-1 h-1 rounded-full bg-[#9b66ff]" />
|
|
||||||
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{!isLogin && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<div className="pt-2">
|
|
||||||
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
|
||||||
{lang === 'ru' ? 'О себе' : 'About me'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={bio}
|
|
||||||
onChange={(e) => setBio(e.target.value)}
|
|
||||||
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
|
|
||||||
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.01 }}
|
|
||||||
whileTap={{ scale: 0.99 }}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
|
||||||
style={{ marginTop: '32px' }}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isLogin ? (lang === 'ru' ? 'Войти' : 'Login') : (lang === 'ru' ? 'Создать аккаунт' : 'Create account')}
|
|
||||||
<ArrowRight size={18} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Футер с переключателем */}
|
|
||||||
{enableRegistration && (
|
|
||||||
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
|
||||||
<p className="text-zinc-500 text-[13px] font-medium">
|
|
||||||
{isLogin ? (lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?") : (lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?')}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsLogin(!isLogin);
|
|
||||||
setError('');
|
|
||||||
setPassword('');
|
|
||||||
setDisplayName('');
|
|
||||||
setBio('');
|
|
||||||
}}
|
|
||||||
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors type-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isLogin ? (lang === 'ru' ? 'Зарегистрироваться' : 'Register') : (lang === 'ru' ? 'Войти' : 'Login')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user