From fb252f9d8758e03d14a9295981ce213e60bd6f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Thu, 19 Mar 2026 22:07:23 +0300 Subject: [PATCH] pre-deep-ddd-refactor --- apps/web/src/App.tsx | 6 +- apps/web/src/components/CallModal.tsx | 4 +- apps/web/src/components/EmojiPicker.tsx | 8 +- apps/web/src/components/ForwardModal.tsx | 4 +- apps/web/src/components/GroupCallModal.tsx | 8 +- apps/web/src/components/SideMenu.tsx | 166 ++----- apps/web/src/components/Sidebar.tsx | 18 +- .../src/components/TelegramImportModal.tsx | 8 +- apps/web/src/lib/api.ts | 418 ------------------ apps/web/src/lib/appApi.ts | 27 ++ apps/web/src/lib/httpClient.ts | 51 +++ .../auth/application}/authStore.ts | 22 +- apps/web/src/modules/auth/domain/types.ts | 11 + .../modules/auth/infrastructure/authApi.ts | 30 ++ .../modules/auth/presentation/AuthPage.tsx | 89 ++++ .../presentation/components/LoginForm.tsx | 127 ++++++ .../presentation/components/RegisterForm.tsx | 160 +++++++ .../chats/application}/chatStore.ts | 16 +- .../modules/chats/infrastructure/chatApi.ts | 112 +++++ .../chats/presentation}/ChatPage.tsx | 26 +- .../presentation}/components/ChatListItem.tsx | 20 +- .../presentation}/components/ChatView.tsx | 34 +- .../components/GroupSettings.tsx | 41 +- .../components/MessageBubble.tsx | 16 +- .../presentation}/components/MessageInput.tsx | 22 +- .../presentation}/components/NewChatModal.tsx | 24 +- .../friends/application/friendStore.ts | 166 +++++++ .../friends/infrastructure/friendApi.ts | 39 ++ .../stories/application/storyStore.ts} | 0 .../stories/infrastructure/storyApi.ts | 66 +++ .../presentation}/components/StoryViewer.tsx | 29 +- .../modules/users/infrastructure/userApi.ts | 59 +++ .../presentation}/components/UserProfile.tsx | 53 +-- apps/web/src/pages/AuthPage.tsx | 247 ----------- 34 files changed, 1152 insertions(+), 975 deletions(-) delete mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/appApi.ts create mode 100644 apps/web/src/lib/httpClient.ts rename apps/web/src/{stores => modules/auth/application}/authStore.ts (84%) create mode 100644 apps/web/src/modules/auth/domain/types.ts create mode 100644 apps/web/src/modules/auth/infrastructure/authApi.ts create mode 100644 apps/web/src/modules/auth/presentation/AuthPage.tsx create mode 100644 apps/web/src/modules/auth/presentation/components/LoginForm.tsx create mode 100644 apps/web/src/modules/auth/presentation/components/RegisterForm.tsx rename apps/web/src/{stores => modules/chats/application}/chatStore.ts (96%) create mode 100644 apps/web/src/modules/chats/infrastructure/chatApi.ts rename apps/web/src/{pages => modules/chats/presentation}/ChatPage.tsx (96%) rename apps/web/src/{ => modules/chats/presentation}/components/ChatListItem.tsx (93%) rename apps/web/src/{ => modules/chats/presentation}/components/ChatView.tsx (97%) rename apps/web/src/{ => modules/chats/presentation}/components/GroupSettings.tsx (96%) rename apps/web/src/{ => modules/chats/presentation}/components/MessageBubble.tsx (98%) rename apps/web/src/{ => modules/chats/presentation}/components/MessageInput.tsx (98%) rename apps/web/src/{ => modules/chats/presentation}/components/NewChatModal.tsx (94%) create mode 100644 apps/web/src/modules/friends/application/friendStore.ts create mode 100644 apps/web/src/modules/friends/infrastructure/friendApi.ts rename apps/web/src/{stores/useStoryStore.ts => modules/stories/application/storyStore.ts} (100%) create mode 100644 apps/web/src/modules/stories/infrastructure/storyApi.ts rename apps/web/src/{ => modules/stories/presentation}/components/StoryViewer.tsx (97%) create mode 100644 apps/web/src/modules/users/infrastructure/userApi.ts rename apps/web/src/{ => modules/users/presentation}/components/UserProfile.tsx (96%) delete mode 100644 apps/web/src/pages/AuthPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 41698e1..3f17cef 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import { AnimatePresence } from 'framer-motion'; -import { useAuthStore } from './stores/authStore'; -import AuthPage from './pages/AuthPage'; -import ChatPage from './pages/ChatPage'; +import { useAuthStore } from './modules/auth/application/authStore'; +import AuthPage from './modules/auth/presentation/AuthPage'; +import ChatPage from './modules/chats/presentation/ChatPage'; import AdminPage from './pages/AdminPage'; import NotificationProvider from './components/NotificationProvider'; diff --git a/apps/web/src/components/CallModal.tsx b/apps/web/src/components/CallModal.tsx index a30d61c..23ba1a9 100644 --- a/apps/web/src/components/CallModal.tsx +++ b/apps/web/src/components/CallModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; 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 { getSocket } from '../lib/socket'; -import { api } from '../lib/api'; +import { UserApi } from '../modules/users/infrastructure/userApi'; import { useLang } from '../lib/i18n'; import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds'; @@ -39,7 +39,7 @@ async function getIceServers(): Promise { return cachedIceConfig; } try { - const data = await api.getIceServers(); + const data = await UserApi.getIceServers(); console.log('[WebRTC] Received ICE config:', data); if (data.iceServers && data.iceServers.length > 0) { cachedIceConfig = { iceServers: data.iceServers }; diff --git a/apps/web/src/components/EmojiPicker.tsx b/apps/web/src/components/EmojiPicker.tsx index 00f9e1c..ed69fee 100644 --- a/apps/web/src/components/EmojiPicker.tsx +++ b/apps/web/src/components/EmojiPicker.tsx @@ -4,8 +4,8 @@ import Picker from '@emoji-mart/react'; import data from '@emoji-mart/data'; import { Search, TrendingUp, Loader2 } from 'lucide-react'; import { useLang } from '../lib/i18n'; -import { useAuthStore } from '../stores/authStore'; -import { api } from '../lib/api'; +import { useAuthStore } from '../modules/auth/application/authStore'; +import { AppApi } from '../lib/appApi'; interface KlipyGif { id: string; @@ -52,7 +52,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) { initialFetchDone.current = true; setGifLoading(true); - api.getTrendingGifs() + AppApi.getTrendingGifs() .then(d => { setTrendingGifs(extractGifs(d)); setGifLoading(false); @@ -72,7 +72,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic return; } setGifLoading(true); - api.searchKlipyGifs(q) + AppApi.searchKlipyGifs(q) .then(d => { setGifs(extractGifs(d)); setGifLoading(false); diff --git a/apps/web/src/components/ForwardModal.tsx b/apps/web/src/components/ForwardModal.tsx index 625ba1d..8e489c1 100644 --- a/apps/web/src/components/ForwardModal.tsx +++ b/apps/web/src/components/ForwardModal.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Search } from 'lucide-react'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; +import { useChatStore } from '../modules/chats/application/chatStore'; +import { useAuthStore } from '../modules/auth/application/authStore'; import { useLang } from '../lib/i18n'; import Avatar from './Avatar'; diff --git a/apps/web/src/components/GroupCallModal.tsx b/apps/web/src/components/GroupCallModal.tsx index c9ef863..c7472c0 100644 --- a/apps/web/src/components/GroupCallModal.tsx +++ b/apps/web/src/components/GroupCallModal.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; +import { useChatStore } from '../modules/chats/application/chatStore'; +import { useAuthStore } from '../modules/auth/application/authStore'; import { getSocket } from '../lib/socket'; import { getMediaUrl } from '../lib/utils'; -import { api } from '../lib/api'; +import { UserApi } from '../modules/users/infrastructure/userApi'; import { useLang } from '../lib/i18n'; interface ParticipantInfo { @@ -46,7 +46,7 @@ const FALLBACK_ICE: RTCConfiguration = { async function getIceServers(): Promise { if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig; try { - const data = await api.getIceServers(); + const data = await UserApi.getIceServers(); if (data.iceServers?.length > 0) { cachedIceConfig = { iceServers: data.iceServers }; iceCacheFetchedAt = Date.now(); diff --git a/apps/web/src/components/SideMenu.tsx b/apps/web/src/components/SideMenu.tsx index ad3f523..786d003 100644 --- a/apps/web/src/components/SideMenu.tsx +++ b/apps/web/src/components/SideMenu.tsx @@ -29,9 +29,10 @@ import { Shield, Eye, } from 'lucide-react'; -import { useAuthStore } from '../stores/authStore'; -import { useChatStore } from '../stores/chatStore'; -import { api } from '../lib/api'; +import { useAuthStore } from '../modules/auth/application/authStore'; +import { useFriendStore } from '../modules/friends/application/friendStore'; +import { useChatStore } from '../modules/chats/application/chatStore'; +import { UserApi } from '../modules/users/infrastructure/userApi'; import { getSocket } from '../lib/socket'; import { useLang } from '../lib/i18n'; import { useThemeStore, ChatTheme } from '../stores/themeStore'; @@ -61,12 +62,23 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro const [showImportModal, setShowImportModal] = useState(false); // Friends state - const [friends, setFriends] = useState([]); - const [friendRequests, setFriendRequests] = useState([]); - const [friendsLoading, setFriendsLoading] = useState(false); - const [friendSearch, setFriendSearch] = useState(''); - const [friendSearchResults, setFriendSearchResults] = useState([]); - const [friendSearchLoading, setFriendSearchLoading] = useState(false); + const { + friends, + friendRequests, + isLoading: friendsLoading, + searchQuery: friendSearch, + 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 }[] = [ { 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 useEffect(() => { - const raw = friendSearch.trim(); - const q = raw.startsWith('@') ? raw.slice(1) : raw; - 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); - } + const timer = setTimeout(() => { + searchFriends(friendSearch, user?.id); }, 400); return () => clearTimeout(timer); - }, [friendSearch, friends, user?.id]); + }, [friendSearch, user?.id, searchFriends]); useEffect(() => { if (!isOpen) { const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300); - setFriendSearch(''); - setFriendSearchResults([]); + clearSearch(); return () => clearTimeout(timer); } // Load friend request count when menu opens - api.getFriendRequests().then(setFriendRequests).catch(() => {}); - }, [isOpen]); + useFriendStore.getState().loadFriends(); + }, [isOpen, clearSearch]); // Real-time friend updates via socket - const loadFriendsRef = useRef(loadFriends); - loadFriendsRef.current = loadFriends; - useEffect(() => { - const socket = getSocket(); - if (!socket) return; - - 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 cleanup = initializeSocketEvents(); + return cleanup; + }, [initializeSocketEvents]); const handleLogout = () => { clearStore(); @@ -411,7 +309,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro onClick={async () => { const newVal = !user?.hideStoryViews; try { - await api.updateSettings({ hideStoryViews: newVal }); + await UserApi.updateSettings({ hideStoryViews: newVal }); useAuthStore.getState().updateUser({ hideStoryViews: newVal }); } catch {} }} @@ -567,7 +465,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro const renderFriends = () => (
-

{t('friends')}

diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a9fbeed..ad9a7dc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,21 +8,21 @@ import { X, User as UserIcon, } from 'lucide-react'; -import { useAuthStore } from '../stores/authStore'; -import { useChatStore } from '../stores/chatStore'; +import { useAuthStore } from '../modules/auth/application/authStore'; +import { useChatStore } from '../modules/chats/application/chatStore'; import { useNotificationStore } from '../stores/notificationStore'; import { useLang } from '../lib/i18n'; -import { api } from '../lib/api'; +import { StoryApi } from '../modules/stories/infrastructure/storyApi'; import { getSocket } from '../lib/socket'; import { getInitials, generateAvatarColor } from '../lib/utils'; import Avatar from './Avatar'; import { StoryGroup } from '../lib/types'; -import ChatListItem from './ChatListItem'; -import NewChatModal from './NewChatModal'; -import UserProfile from './UserProfile'; +import ChatListItem from '../modules/chats/presentation/components/ChatListItem'; +import NewChatModal from '../modules/chats/presentation/components/NewChatModal'; +import UserProfile from '../modules/users/presentation/components/UserProfile'; import SideMenu from './SideMenu'; -import StoryViewer, { CreateStoryModal } from './StoryViewer'; -import { useStoryStore } from '../stores/useStoryStore'; +import StoryViewer, { CreateStoryModal } from '../modules/stories/presentation/components/StoryViewer'; +import { useStoryStore } from '../modules/stories/application/storyStore'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -37,7 +37,7 @@ export default function Sidebar() { const [showCreateStory, setShowCreateStory] = useState(false); const loadStories = () => { - api.getStories() + StoryApi.getStories() .then(setStoryGroups) .catch((err) => { console.error(err); diff --git a/apps/web/src/components/TelegramImportModal.tsx b/apps/web/src/components/TelegramImportModal.tsx index 1f60c5f..cd25129 100644 --- a/apps/web/src/components/TelegramImportModal.tsx +++ b/apps/web/src/components/TelegramImportModal.tsx @@ -1,10 +1,10 @@ import { useState, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; 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 type { User as UserType, FriendWithId } from '../lib/types'; -import { useAuthStore } from '../stores/authStore'; +import { useAuthStore } from '../modules/auth/application/authStore'; interface TelegramImportModalProps { isOpen: boolean; @@ -41,7 +41,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr setLoading(true); try { - const data = await api.analyzeTelegramImport(selectedFile) as any; + const data = await AppApi.analyzeTelegramImport(selectedFile) as any; setToken(data.token); setNames(data.names); @@ -66,7 +66,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr setError(null); 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: 'Успешно импортировано' }); setStep(3); } catch (err: any) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts deleted file mode 100644 index b6cde75..0000000 --- a/apps/web/src/lib/api.ts +++ /dev/null @@ -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(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise { - 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 = { - ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), - ...(fetchOptions.headers as Record), - }; - - 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('/config'); - } - - // \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438 - async searchUsers(query: string) { - return this.request(`/users/search?q=${encodeURIComponent(query)}`); - } - - async getUser(id: string) { - return this.request(`/users/${id}`); - } - - async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) { - return this.request('/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; - } - - 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; - } - - async removeAvatar() { - return this.request('/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(`/messages/search?${params}`); - } - - // \u0427\u0430\u0442\u044b - async getChats() { - return this.request('/chats'); - } - - async createPersonalChat(userId: string) { - return this.request('/chats/personal', { - method: 'POST', - body: JSON.stringify({ userId }), - }); - } - - async createGroupChat(name: string, memberIds: string[]) { - return this.request('/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(`/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(`/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; - } - - 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; - } - - async removeGroupAvatar(chatId: string) { - return this.request(`/chats/${chatId}/avatar`, { method: 'DELETE' }); - } - - async addGroupMembers(chatId: string, userIds: string[]) { - return this.request(`/chats/${chatId}/members`, { - method: 'POST', - body: JSON.stringify({ userIds }), - }); - } - - async removeGroupMember(chatId: string, userId: string) { - return this.request(`/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(`/messages/chat/${chatId}/shared?type=${type}`); - } - - - // Stories - async getStories() { - return this.request('/stories'); - } - - async getUserStories(userId: string) { - return this.request(`/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>(`/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>(`/stories/${storyId}/replies`); - } - - // Favorites chat - async getOrCreateFavorites() { - return this.request('/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; groupName?: string }) { - return this.request('/import/telegram/execute', { - method: 'POST', - body: JSON.stringify(req), - }); - } - - // Friends - async getFriends() { - return this.request('/friends'); - } - - async getFriendRequests() { - return this.request('/friends/requests'); - } - - async getOutgoingRequests() { - return this.request('/friends/outgoing'); - } - - async getFriendshipStatus(userId: string) { - return this.request(`/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('/klipy/trending'); - } - - async searchKlipyGifs(query: string) { - return this.request(`/klipy/search?q=${encodeURIComponent(query)}`); - } -} - -export const api = new ApiClient(); diff --git a/apps/web/src/lib/appApi.ts b/apps/web/src/lib/appApi.ts new file mode 100644 index 0000000..d88b661 --- /dev/null +++ b/apps/web/src/lib/appApi.ts @@ -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; groupName?: string }) { + return httpClient.request('/import/telegram/execute', { + method: 'POST', + body: JSON.stringify(req), + }); + } + + static async getTrendingGifs() { + return httpClient.request('/klipy/trending'); + } + + static async searchKlipyGifs(query: string) { + return httpClient.request(`/klipy/search?q=${encodeURIComponent(query)}`); + } +} diff --git a/apps/web/src/lib/httpClient.ts b/apps/web/src/lib/httpClient.ts new file mode 100644 index 0000000..d18d668 --- /dev/null +++ b/apps/web/src/lib/httpClient.ts @@ -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(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise { + 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 = { + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + ...(fetchOptions.headers as Record), + }; + + 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(); diff --git a/apps/web/src/stores/authStore.ts b/apps/web/src/modules/auth/application/authStore.ts similarity index 84% rename from apps/web/src/stores/authStore.ts rename to apps/web/src/modules/auth/application/authStore.ts index 2c18c5b..eb7c8e7 100644 --- a/apps/web/src/stores/authStore.ts +++ b/apps/web/src/modules/auth/application/authStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { api } from '../lib/api'; -import { connectSocket, disconnectSocket } from '../lib/socket'; -import type { User } from '../lib/types'; +import { AuthApi } from '../infrastructure/authApi'; +import { connectSocket, disconnectSocket } from '../../../lib/socket'; +import type { User } from '../../../lib/types'; interface AuthState { token: string | null; @@ -27,7 +27,7 @@ export const useAuthStore = create((set, get) => ({ fetchConfig: async () => { if (!get().token) return; try { - const res = await api.getConfig(); + const res = await AuthApi.getConfig(); set({ config: res }); } catch {} }, @@ -35,9 +35,9 @@ export const useAuthStore = create((set, get) => ({ login: async (username, password) => { try { 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); - api.setToken(token); + AuthApi.setToken(token); connectSocket(token); set({ token, user, isLoading: false }); await get().fetchConfig(); @@ -51,9 +51,9 @@ export const useAuthStore = create((set, get) => ({ register: async (username, displayName, password, bio) => { try { 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); - api.setToken(token); + AuthApi.setToken(token); connectSocket(token); set({ token, user, isLoading: false }); await get().fetchConfig(); @@ -66,7 +66,7 @@ export const useAuthStore = create((set, get) => ({ logout: () => { localStorage.removeItem('knot_token'); - api.setToken(null); + AuthApi.setToken(null); disconnectSocket(); set({ token: null, user: null }); }, @@ -82,8 +82,8 @@ export const useAuthStore = create((set, get) => ({ let lastError: unknown; for (let attempt = 0; attempt < 3; attempt++) { try { - api.setToken(token); - const { user } = await api.getMe(); + AuthApi.setToken(token); + const { user } = await AuthApi.getMe(); connectSocket(token); set({ user, isLoading: false }); await get().fetchConfig(); diff --git a/apps/web/src/modules/auth/domain/types.ts b/apps/web/src/modules/auth/domain/types.ts new file mode 100644 index 0000000..7645f65 --- /dev/null +++ b/apps/web/src/modules/auth/domain/types.ts @@ -0,0 +1,11 @@ +export interface LoginCredentials { + username: string; + password: string; +} + +export interface RegisterCredentials { + username: string; + displayName: string; + password: string; + bio?: string; +} diff --git a/apps/web/src/modules/auth/infrastructure/authApi.ts b/apps/web/src/modules/auth/infrastructure/authApi.ts new file mode 100644 index 0000000..41d2055 --- /dev/null +++ b/apps/web/src/modules/auth/infrastructure/authApi.ts @@ -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('/config'); + } + + static setToken(token: string | null) { + httpClient.setToken(token); + } +} diff --git a/apps/web/src/modules/auth/presentation/AuthPage.tsx b/apps/web/src/modules/auth/presentation/AuthPage.tsx new file mode 100644 index 0000000..e034e3d --- /dev/null +++ b/apps/web/src/modules/auth/presentation/AuthPage.tsx @@ -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 ( + + {/* Переключатель языка сверху по центру */} +
+ +
+ +
+ + {/* Карточка авторизации */} + +
+ + {/* Заголовок */} +
+ + + +

Knot Messenger

+

+ {isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')} +

+
+ + + + {isLogin ? ( + setIsLogin(false)} + /> + ) : ( + setIsLogin(true)} + /> + )} + + +
+
+ + ); +} diff --git a/apps/web/src/modules/auth/presentation/components/LoginForm.tsx b/apps/web/src/modules/auth/presentation/components/LoginForm.tsx new file mode 100644 index 0000000..2245c32 --- /dev/null +++ b/apps/web/src/modules/auth/presentation/components/LoginForm.tsx @@ -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({ 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 ( + <> + + {error && ( + + {error} + + )} + + +
+
+ + 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" + /> +
+ +
+ +
+ 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" + /> + +
+
+ + + {isSubmitting ? ( +
+ ) : ( + <> + {lang === 'ru' ? 'Войти' : 'Login'} + + + )} + + + + {enableRegistration && onRegisterClick && ( +
+

+ {lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"} +

+ +
+ )} + + ); +} diff --git a/apps/web/src/modules/auth/presentation/components/RegisterForm.tsx b/apps/web/src/modules/auth/presentation/components/RegisterForm.tsx new file mode 100644 index 0000000..dbb246f --- /dev/null +++ b/apps/web/src/modules/auth/presentation/components/RegisterForm.tsx @@ -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({ 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 ( + <> + + {error && ( + + {error} + + )} + + +
+
+ + 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" + /> +
+ +
+ + 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]" + /> +
+ +
+ +
+ 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} + /> + +
+

+ + {lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'} +

+
+ +
+ + 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]" + /> +
+ + + {isSubmitting ? ( +
+ ) : ( + <> + {lang === 'ru' ? 'Создать аккаунт' : 'Create account'} + + + )} + + + + {enableRegistration && ( +
+

+ {lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'} +

+ +
+ )} + + ); +} diff --git a/apps/web/src/stores/chatStore.ts b/apps/web/src/modules/chats/application/chatStore.ts similarity index 96% rename from apps/web/src/stores/chatStore.ts rename to apps/web/src/modules/chats/application/chatStore.ts index 6370593..21f9ede 100644 --- a/apps/web/src/stores/chatStore.ts +++ b/apps/web/src/modules/chats/application/chatStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { api } from '../lib/api'; -import { useAuthStore } from './authStore'; -import type { Chat, ChatMember, Message, TypingUser } from '../lib/types'; +import { ChatApi } from '../infrastructure/chatApi'; +import { useAuthStore } from '../../auth/application/authStore'; +import type { Chat, ChatMember, Message, TypingUser } from '../../../lib/types'; interface ChatState { chats: Chat[]; @@ -90,11 +90,11 @@ export const useChatStore = create((set, get) => ({ loadChats: async () => { try { set({ isLoadingChats: true }); - const chats = await api.getChats(); + const chats = await ChatApi.getChats(); // Auto-create favorites chat if not present if (!chats.some((c: any) => c.type === 'favorites')) { try { - const favChat = await api.getOrCreateFavorites(); + const favChat = await ChatApi.getOrCreateFavorites(); chats.unshift(favChat); } catch { } } @@ -109,7 +109,7 @@ export const useChatStore = create((set, get) => ({ } catch (error: any) { console.error('Load chats error:', error); 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'); } }, @@ -125,7 +125,7 @@ export const useChatStore = create((set, get) => ({ const currentMessages = state.messages[chatId] || []; 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) => { // Merge fetched messages with any that arrived via socket @@ -144,7 +144,7 @@ export const useChatStore = create((set, get) => ({ } catch (error: any) { console.error('Load messages error:', error); 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'); } }, diff --git a/apps/web/src/modules/chats/infrastructure/chatApi.ts b/apps/web/src/modules/chats/infrastructure/chatApi.ts new file mode 100644 index 0000000..dc6f46f --- /dev/null +++ b/apps/web/src/modules/chats/infrastructure/chatApi.ts @@ -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('/chats'); + } + + static async createPersonalChat(userId: string) { + return httpClient.request('/chats/personal', { + method: 'POST', + body: JSON.stringify({ userId }), + }); + } + + static async createGroupChat(name: string, memberIds: string[]) { + return httpClient.request('/chats/group', { + method: 'POST', + body: JSON.stringify({ name, memberIds }), + }); + } + + static async getMessages(chatId: string, cursor?: string) { + const params = cursor ? `?cursor=${cursor}` : ''; + return httpClient.request(`/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(`/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(`/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(`/chats/${chatId}/avatar/crop`, { + method: 'POST', + body: formData, + timeout: 120_000, + }); + } + + static async removeGroupAvatar(chatId: string) { + return httpClient.request(`/chats/${chatId}/avatar`, { method: 'DELETE' }); + } + + static async addGroupMembers(chatId: string, userIds: string[]) { + return httpClient.request(`/chats/${chatId}/members`, { + method: 'POST', + body: JSON.stringify({ userIds }), + }); + } + + static async removeGroupMember(chatId: string, userId: string) { + return httpClient.request(`/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(`/messages/search?${params}`); + } + + static async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') { + return httpClient.request(`/messages/chat/${chatId}/shared?type=${type}`); + } + + static async getOrCreateFavorites() { + return httpClient.request('/chats/favorites', { method: 'POST' }); + } +} diff --git a/apps/web/src/pages/ChatPage.tsx b/apps/web/src/modules/chats/presentation/ChatPage.tsx similarity index 96% rename from apps/web/src/pages/ChatPage.tsx rename to apps/web/src/modules/chats/presentation/ChatPage.tsx index 2dd83b3..6a0cb0b 100644 --- a/apps/web/src/pages/ChatPage.tsx +++ b/apps/web/src/modules/chats/presentation/ChatPage.tsx @@ -1,17 +1,17 @@ import { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; -import { getSocket, disconnectSocket } from '../lib/socket'; -import { api } from '../lib/api'; -import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../lib/sounds'; -import { useLang } from '../lib/i18n'; -import type { Message, UserBasic, CallInfo } from '../lib/types'; +import { useChatStore } from '../application/chatStore'; +import { useAuthStore } from '../../auth/application/authStore'; +import { getSocket, disconnectSocket } from '../../../lib/socket'; +import { ChatApi } from '../infrastructure/chatApi'; +import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../lib/sounds'; +import { useLang } from '../../../lib/i18n'; +import type { Message, UserBasic, CallInfo } from '../../../lib/types'; import { Send, Check, Phone, PhoneOff } from 'lucide-react'; -import Sidebar from '../components/Sidebar'; -import ChatView from '../components/ChatView'; -import CallModal from '../components/CallModal'; -import GroupCallModal from '../components/GroupCallModal'; +import Sidebar from '../../../components/Sidebar'; +import ChatView from './components/ChatView'; +import CallModal from '../../../components/CallModal'; +import GroupCallModal from '../../../components/GroupCallModal'; export default function ChatPage() { const { @@ -94,7 +94,7 @@ export default function ChatPage() { const { chats } = useChatStore.getState(); if (!chats.some(c => c.id === message.chatId)) { try { - const allChats = await api.getChats(); + const allChats = await ChatApi.getChats(); const newChat = allChats.find(c => c.id === message.chatId); if (newChat) { // Reset unreadCount to 0 because addMessage below will increment it by 1 @@ -116,7 +116,7 @@ export default function ChatPage() { const { chats } = useChatStore.getState(); if (!chats.some(c => c.id === message.chatId)) { try { - const allChats = await api.getChats(); + const allChats = await ChatApi.getChats(); const newChat = allChats.find(c => c.id === message.chatId); if (newChat) useChatStore.getState().addChat(newChat); } catch (_) { /* ignore */ } diff --git a/apps/web/src/components/ChatListItem.tsx b/apps/web/src/modules/chats/presentation/components/ChatListItem.tsx similarity index 93% rename from apps/web/src/components/ChatListItem.tsx rename to apps/web/src/modules/chats/presentation/components/ChatListItem.tsx index 1f13088..49f610d 100644 --- a/apps/web/src/components/ChatListItem.tsx +++ b/apps/web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -2,14 +2,14 @@ import { useState, useRef, useEffect, memo } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { ru, enUS } from 'date-fns/locale'; import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react'; -import { useAuthStore } from '../stores/authStore'; -import { useChatStore } from '../stores/chatStore'; -import { useLang } from '../lib/i18n'; -import { stripMarkdown } from '../lib/utils'; -import { api } from '../lib/api'; -import ConfirmModal from './ConfirmModal'; -import Avatar from './Avatar'; -import type { Chat } from '../lib/types'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { useChatStore } from '../../application/chatStore'; +import { useLang } from '../../../../lib/i18n'; +import { stripMarkdown } from '../../../../lib/utils'; +import { ChatApi } from '../../infrastructure/chatApi'; +import ConfirmModal from '../../../../components/ConfirmModal'; +import Avatar from '../../../../components/Avatar'; +import type { Chat } from '../../../../lib/types'; interface ChatListItemProps { chat: Chat; @@ -98,7 +98,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { const handlePin = async () => { setCtxMenu(null); try { - await api.togglePinChat(chat.id); + await ChatApi.togglePinChat(chat.id); loadChats(); } catch (e) { console.error(e); } }; @@ -111,7 +111,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { const confirmDelete = async () => { setShowDeleteConfirm(false); try { - await api.deleteChat(chat.id); + await ChatApi.deleteChat(chat.id); useChatStore.getState().removeChat(chat.id); } catch (e) { console.error(e); } }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/modules/chats/presentation/components/ChatView.tsx similarity index 97% rename from apps/web/src/components/ChatView.tsx rename to apps/web/src/modules/chats/presentation/components/ChatView.tsx index 1efdcb2..0230a9b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/modules/chats/presentation/components/ChatView.tsx @@ -19,23 +19,23 @@ import { ArrowLeft, MessageSquare, } from 'lucide-react'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; -import { api } from '../lib/api'; -import { getSocket } from '../lib/socket'; -import { isChatMuted, toggleMuteChat } from '../lib/sounds'; -import { useLang } from '../lib/i18n'; -import { formatLastSeen } from '../lib/utils'; -import type { UserBasic, Message } from '../lib/types'; +import { useChatStore } from '../../application/chatStore'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { ChatApi } from '../../infrastructure/chatApi'; +import { getSocket } from '../../../../lib/socket'; +import { isChatMuted, toggleMuteChat } from '../../../../lib/sounds'; +import { useLang } from '../../../../lib/i18n'; +import { formatLastSeen } from '../../../../lib/utils'; +import type { UserBasic, Message } from '../../../../lib/types'; import MessageBubble from './MessageBubble'; import MessageInput from './MessageInput'; -import TypingIndicator from './TypingIndicator'; -import UserProfile from './UserProfile'; +import TypingIndicator from '../../../../components/TypingIndicator'; +import UserProfile from '../../../users/presentation/components/UserProfile'; import GroupSettings from './GroupSettings'; -import ForwardModal from './ForwardModal'; -import ConfirmModal from './ConfirmModal'; -import Avatar from './Avatar'; -import { useThemeStore } from '../stores/themeStore'; +import ForwardModal from '../../../../components/ForwardModal'; +import ConfirmModal from '../../../../components/ConfirmModal'; +import Avatar from '../../../../components/Avatar'; +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 }) { const { user, config } = useAuthStore(); @@ -353,7 +353,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } const timer = setTimeout(async () => { try { - const results = await api.searchMessages(searchText, activeChat); + const results = await ChatApi.searchMessages(searchText, activeChat); setSearchResults(results); } catch (e) { console.error(e); @@ -722,7 +722,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal message: t('clearChatConfirm'), action: async () => { try { - await api.clearChat(activeChat); + await ChatApi.clearChat(activeChat); useChatStore.getState().clearMessages(activeChat); } catch (e) { console.error(e); @@ -744,7 +744,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal message: t('deleteChatConfirm'), action: async () => { try { - await api.deleteChat(activeChat); + await ChatApi.deleteChat(activeChat); useChatStore.getState().removeChat(activeChat); } catch (e) { console.error(e); diff --git a/apps/web/src/components/GroupSettings.tsx b/apps/web/src/modules/chats/presentation/components/GroupSettings.tsx similarity index 96% rename from apps/web/src/components/GroupSettings.tsx rename to apps/web/src/modules/chats/presentation/components/GroupSettings.tsx index d41bae2..fc6bfdc 100644 --- a/apps/web/src/components/GroupSettings.tsx +++ b/apps/web/src/modules/chats/presentation/components/GroupSettings.tsx @@ -20,16 +20,17 @@ import { Video } from 'lucide-react'; import Cropper from 'react-easy-crop'; -import { api } from '../lib/api'; -import { useAuthStore } from '../stores/authStore'; -import { useChatStore } from '../stores/chatStore'; -import { useLang } from '../lib/i18n'; -import { Chat, UserPresence, Message } from '../lib/types'; -import Avatar from './Avatar'; -import ConfirmModal from './ConfirmModal'; -import ImageLightbox from './ImageLightbox'; -import { getMediaUrl } from '../lib/utils'; -import { getCroppedImg } from '../lib/imageCrop'; +import { ChatApi } from '../../infrastructure/chatApi'; +import { UserApi } from '../../../users/infrastructure/userApi'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { useChatStore } from '../../application/chatStore'; +import { useLang } from '../../../../lib/i18n'; +import { Chat, UserPresence, Message } from '../../../../lib/types'; +import Avatar from '../../../../components/Avatar'; +import ConfirmModal from '../../../../components/ConfirmModal'; +import ImageLightbox from '../../../../components/ImageLightbox'; +import { getMediaUrl } from '../../../../lib/utils'; +import { getCroppedImg } from '../../../../lib/imageCrop'; interface GroupSettingsProps { chat: Chat; @@ -91,7 +92,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const timer = setTimeout(async () => { try { setIsSearching(true); - const results = await api.searchUsers(searchQuery); + const results = await UserApi.searchUsers(searchQuery); // Filter out users already in the group const memberIds = new Set(chat.members.map((m) => m.user.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; try { setIsSaving(true); - const updatedChat = await api.updateGroup(chat.id, { name: groupName.trim() }); + const updatedChat = await ChatApi.updateGroup(chat.id, { name: groupName.trim() }); updateChat(updatedChat); setIsEditingName(false); } catch (e) { @@ -121,7 +122,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const handleSaveDesc = async () => { try { setIsSaving(true); - const updatedChat = await api.updateGroup(chat.id, { description: groupDesc.trim() }); + const updatedChat = await ChatApi.updateGroup(chat.id, { description: groupDesc.trim() }); updateChat(updatedChat); setIsEditingDesc(false); } catch (e) { @@ -151,7 +152,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels); 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 }); setIsCropping(false); @@ -170,7 +171,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet if (!file) return; try { setAvatarUploading(true); - const updatedChat = await api.uploadGroupAvatar(chat.id, file); + const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, file); updateChat(updatedChat); } catch (e) { console.error(e); @@ -183,7 +184,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const handleRemoveAvatar = async () => { try { setAvatarUploading(true); - const updatedChat = await api.removeGroupAvatar(chat.id); + const updatedChat = await ChatApi.removeGroupAvatar(chat.id); updateChat(updatedChat); } catch (e) { console.error(e); @@ -194,7 +195,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const handleAddMember = async (userId: string) => { try { - const updatedChat = await api.addGroupMembers(chat.id, [userId]); + const updatedChat = await ChatApi.addGroupMembers(chat.id, [userId]); updateChat(updatedChat); setSearchQuery(''); setSearchResults([]); @@ -210,7 +211,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const confirmRemoveMember = async () => { if (!removeTargetId) return; try { - const updatedChat = await api.removeGroupMember(chat.id, removeTargetId); + const updatedChat = await ChatApi.removeGroupMember(chat.id, removeTargetId); updateChat(updatedChat); } catch (e) { console.error(e); @@ -229,7 +230,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet if (loadedTabs.has(tab)) return; setTabLoading(true); try { - const data = await api.getSharedMedia(chat.id, tab); + const data = await ChatApi.getSharedMedia(chat.id, tab); if (tab === 'media') setSharedMedia(data); else if (tab === 'gifs') setSharedGifs(data); else if (tab === 'files') setSharedFiles(data); @@ -385,7 +386,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet onClick={async (e) => { e.stopPropagation(); try { - const updatedChat = await api.removeGroupAvatar(chat.id); + const updatedChat = await ChatApi.removeGroupAvatar(chat.id); updateChat(updatedChat); } catch (e) { console.error('Failed to remove avatar', e); diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/modules/chats/presentation/components/MessageBubble.tsx similarity index 98% rename from apps/web/src/components/MessageBubble.tsx rename to apps/web/src/modules/chats/presentation/components/MessageBubble.tsx index f1c3881..bf5fe3d 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -20,14 +20,14 @@ import { Clock, Forward, } from 'lucide-react'; -import { useAuthStore } from '../stores/authStore'; -import { useChatStore } from '../stores/chatStore'; -import { getSocket } from '../lib/socket'; -import { useLang } from '../lib/i18n'; -import { extractWaveform, getMediaUrl } from '../lib/utils'; -import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types'; -import ImageLightbox from './ImageLightbox'; -import LinkPreview from './LinkPreview'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { useChatStore } from '../../application/chatStore'; +import { getSocket } from '../../../../lib/socket'; +import { useLang } from '../../../../lib/i18n'; +import { extractWaveform, getMediaUrl } from '../../../../lib/utils'; +import type { Message, MediaItem, Reaction, ChatMember } from '../../../../lib/types'; +import ImageLightbox from '../../../../components/ImageLightbox'; +import LinkPreview from '../../../../components/LinkPreview'; interface MessageBubbleProps { message: Message; diff --git a/apps/web/src/components/MessageInput.tsx b/apps/web/src/modules/chats/presentation/components/MessageInput.tsx similarity index 98% rename from apps/web/src/components/MessageInput.tsx rename to apps/web/src/modules/chats/presentation/components/MessageInput.tsx index 41b569a..766d179 100644 --- a/apps/web/src/components/MessageInput.tsx +++ b/apps/web/src/modules/chats/presentation/components/MessageInput.tsx @@ -17,14 +17,14 @@ import { Calendar, Check, } from 'lucide-react'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; -import { api } from '../lib/api'; -import { getSocket } from '../lib/socket'; -import { useLang } from '../lib/i18n'; -import { AUDIO_EXTENSIONS, MAX_FILE_SIZE } from '../lib/types'; -import { useNotificationStore } from '../stores/notificationStore'; -import EmojiPicker from './EmojiPicker'; +import { useChatStore } from '../../application/chatStore'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { ChatApi } from '../../infrastructure/chatApi'; +import { getSocket } from '../../../../lib/socket'; +import { useLang } from '../../../../lib/i18n'; +import { AUDIO_EXTENSIONS, MAX_FILE_SIZE } from '../../../../lib/types'; +import { useNotificationStore } from '../../../../stores/notificationStore'; +import EmojiPicker from '../../../../components/EmojiPicker'; interface Attachment { file: File; @@ -203,7 +203,7 @@ export default function MessageInput({ chatId }: MessageInputProps) { if (hasAttachments) { setIsSending(true); 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 socketAttachments = results.map((res, i) => ({ @@ -412,7 +412,7 @@ export default function MessageInput({ chatId }: MessageInputProps) { const file = new File([blob], `voice.${ext}`, { type: mimeType }); try { - const result = await api.uploadFile(file); + const result = await ChatApi.uploadFile(file); const socket = getSocket(); if (socket) { socket.emit('send_message', { @@ -1261,4 +1261,4 @@ function ScheduleCalendar({
); -} \ No newline at end of file +} diff --git a/apps/web/src/components/NewChatModal.tsx b/apps/web/src/modules/chats/presentation/components/NewChatModal.tsx similarity index 94% rename from apps/web/src/components/NewChatModal.tsx rename to apps/web/src/modules/chats/presentation/components/NewChatModal.tsx index 4c77161..543cbf0 100644 --- a/apps/web/src/components/NewChatModal.tsx +++ b/apps/web/src/modules/chats/presentation/components/NewChatModal.tsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react'; -import { api } from '../lib/api'; -import { useChatStore } from '../stores/chatStore'; -import { useAuthStore } from '../stores/authStore'; -import { useLang } from '../lib/i18n'; -import type { UserPresence, FriendWithId } from '../lib/types'; +import { ChatApi } from '../../infrastructure/chatApi'; +import { UserApi } from '../../../users/infrastructure/userApi'; +import { FriendApi } from '../../../friends/infrastructure/friendApi'; +import { useChatStore } from '../../application/chatStore'; +import { useAuthStore } from '../../../../modules/auth/application/authStore'; +import { useLang } from '../../../../lib/i18n'; +import type { UserPresence, FriendWithId } from '../../../../lib/types'; interface NewChatModalProps { onClose: () => void; @@ -28,7 +30,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { // Load friends on mount useEffect(() => { - api.getFriends().then(setFriends).catch(() => {}); + FriendApi.getFriends().then(setFriends).catch(() => {}); }, []); useEffect(() => { @@ -39,7 +41,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { const timer = setTimeout(async () => { try { setIsLoading(true); - const results = await api.searchUsers(query); + const results = await UserApi.searchUsers(query); setUsers(results.filter((u) => u.id !== user?.id)); } catch (e) { console.error(e); @@ -53,9 +55,9 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { const handleSelectUser = async (selectedUser: UserPresence) => { if (mode === 'personal') { try { - const chat = await api.createPersonalChat(selectedUser.id); + const chat = await ChatApi.createPersonalChat(selectedUser.id); 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); loadMessages(chat.id); onClose(); @@ -76,12 +78,12 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { if (!groupName.trim() || selectedUsers.length === 0) return; setIsCreating(true); try { - const chat = await api.createGroupChat( + const chat = await ChatApi.createGroupChat( groupName.trim(), selectedUsers.map((u) => u.id) ); 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); loadMessages(chat.id); onClose(); diff --git a/apps/web/src/modules/friends/application/friendStore.ts b/apps/web/src/modules/friends/application/friendStore.ts new file mode 100644 index 0000000..5bc38d9 --- /dev/null +++ b/apps/web/src/modules/friends/application/friendStore.ts @@ -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; + acceptRequest: (requestId: string) => Promise; + declineRequest: (requestId: string) => Promise; + removeFriend: (friendshipId: string) => Promise; + sendRequest: (friendId: string) => Promise; + searchFriends: (query: string, currentUserId?: string) => Promise; + clearSearch: () => void; + initializeSocketEvents: () => () => void; +} + +export const useFriendStore = create((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); + }; + } +})); diff --git a/apps/web/src/modules/friends/infrastructure/friendApi.ts b/apps/web/src/modules/friends/infrastructure/friendApi.ts new file mode 100644 index 0000000..22e5d0b --- /dev/null +++ b/apps/web/src/modules/friends/infrastructure/friendApi.ts @@ -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('/friends'); + } + + static async getFriendRequests() { + return httpClient.request('/friends/requests'); + } + + static async getOutgoingRequests() { + return httpClient.request('/friends/outgoing'); + } + + static async getFriendshipStatus(userId: string) { + return httpClient.request(`/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' }); + } +} diff --git a/apps/web/src/stores/useStoryStore.ts b/apps/web/src/modules/stories/application/storyStore.ts similarity index 100% rename from apps/web/src/stores/useStoryStore.ts rename to apps/web/src/modules/stories/application/storyStore.ts diff --git a/apps/web/src/modules/stories/infrastructure/storyApi.ts b/apps/web/src/modules/stories/infrastructure/storyApi.ts new file mode 100644 index 0000000..864f8c6 --- /dev/null +++ b/apps/web/src/modules/stories/infrastructure/storyApi.ts @@ -0,0 +1,66 @@ +import { httpClient } from '../../../lib/httpClient'; +import type { StoryGroup } from '../../../lib/types'; + +export class StoryApi { + static async getStories() { + return httpClient.request('/stories'); + } + + static async getUserStories(userId: string) { + return httpClient.request(`/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>(`/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>(`/stories/${storyId}/replies`); + } +} diff --git a/apps/web/src/components/StoryViewer.tsx b/apps/web/src/modules/stories/presentation/components/StoryViewer.tsx similarity index 97% rename from apps/web/src/components/StoryViewer.tsx rename to apps/web/src/modules/stories/presentation/components/StoryViewer.tsx index feae988..02bdd9e 100644 --- a/apps/web/src/components/StoryViewer.tsx +++ b/apps/web/src/modules/stories/presentation/components/StoryViewer.tsx @@ -1,13 +1,14 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react'; -import { useAuthStore } from '../stores/authStore'; -import { api } from '../lib/api'; -import { getSocket } from '../lib/socket'; -import { useLang } from '../lib/i18n'; -import Avatar from './Avatar'; -import { StoryGroup } from '../lib/types'; -import { getMediaUrl } from '../lib/utils'; +import { useAuthStore } from '../../../auth/application/authStore'; +import { StoryApi } from '../../infrastructure/storyApi'; +import { ChatApi } from '../../../chats/infrastructure/chatApi'; +import { getSocket } from '../../../../lib/socket'; +import { useLang } from '../../../../lib/i18n'; +import Avatar from '../../../../components/Avatar'; +import { StoryGroup } from '../../../../lib/types'; +import { getMediaUrl } from '../../../../lib/utils'; 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 viewCount = currentStory.viewCount || 0; - api.viewStory(storyId).then(() => { + StoryApi.viewStory(storyId).then(() => { // console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1); setViewOverrides(prev => ({ ...prev, @@ -265,7 +266,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd if (!currentStory) return; const storyId = currentStory.id; try { - await api.deleteStory(storyId); + await StoryApi.deleteStory(storyId); if (currentUser.stories.length > 1) { if (storyIndex >= currentUser.stories.length - 1) { @@ -308,7 +309,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd })); try { - await api.addStoryReaction(currentStory.id, emoji); + await StoryApi.addStoryReaction(currentStory.id, emoji); } catch (e) { console.error('Add reaction error:', e); } @@ -319,7 +320,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd setSendingReply(true); try { - await api.addStoryReply(currentStory.id, replyText.trim()); + await StoryApi.addStoryReply(currentStory.id, replyText.trim()); setReplyText(''); setShowReplyInput(false); } catch (e) { @@ -430,7 +431,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd setPaused(true); setShowViewers(true); setViewersLoading(true); - api.getStoryViewers(currentStory.id).then(v => { + StoryApi.getStoryViewers(currentStory.id).then(v => { setViewers(v); setViewersLoading(false); }).catch(() => setViewersLoading(false)); @@ -659,11 +660,11 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) try { let mediaUrl: string | undefined; if (imageFile) { - const result = await api.uploadFile(imageFile); + const result = await ChatApi.uploadFile(imageFile); mediaUrl = result.url; } - await api.createStory({ + await StoryApi.createStory({ type: imageFile?.type.startsWith('video/') ? 'video' : mode, content: mode === 'text' ? text.trim() : undefined, bgColor: mode === 'text' ? bgColor : undefined, diff --git a/apps/web/src/modules/users/infrastructure/userApi.ts b/apps/web/src/modules/users/infrastructure/userApi.ts new file mode 100644 index 0000000..1adc8de --- /dev/null +++ b/apps/web/src/modules/users/infrastructure/userApi.ts @@ -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(`/users/search?q=${encodeURIComponent(query)}`); + } + + static async getUser(id: string) { + return httpClient.request(`/users/${id}`); + } + + static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) { + return httpClient.request('/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('/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('/users/avatar/crop', { + method: 'POST', + body: formData, + timeout: 120_000, + }); + } + + static async removeAvatar() { + return httpClient.request('/users/avatar', { method: 'DELETE' }); + } + + static async getIceServers() { + return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers'); + } +} diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/modules/users/presentation/components/UserProfile.tsx similarity index 96% rename from apps/web/src/components/UserProfile.tsx rename to apps/web/src/modules/users/presentation/components/UserProfile.tsx index d09a9a2..d146b69 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/modules/users/presentation/components/UserProfile.tsx @@ -2,20 +2,23 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react'; import Cropper from 'react-easy-crop'; -import { api } from '../lib/api'; -import { useAuthStore } from '../stores/authStore'; -import { useLang } from '../lib/i18n'; -import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types'; -import ConfirmModal from './ConfirmModal'; -import ImageLightbox from './ImageLightbox'; -import StoryViewer from './StoryViewer'; -import { getSocket } from '../lib/socket'; -import { useStoryStore } from '../stores/useStoryStore'; -import { getMediaUrl } from '../lib/utils'; -import { getCroppedImg } from '../lib/imageCrop'; -import DatePicker from './DatePicker'; -import { useChatStore } from '../stores/chatStore'; -import { toggleMuteChat, isChatMuted } from '../lib/sounds'; +import { UserApi } from '../../infrastructure/userApi'; +import { ChatApi } from '../../../chats/infrastructure/chatApi'; +import { FriendApi } from '../../../friends/infrastructure/friendApi'; +import { StoryApi } from '../../../stories/infrastructure/storyApi'; +import { useAuthStore } from '../../../auth/application/authStore'; +import { useLang } from '../../../../lib/i18n'; +import { User, Message, FriendshipStatus, StoryGroup } from '../../../../lib/types'; +import ConfirmModal from '../../../../components/ConfirmModal'; +import ImageLightbox from '../../../../components/ImageLightbox'; +import StoryViewer from '../../../../modules/stories/presentation/components/StoryViewer'; +import { getSocket } from '../../../../lib/socket'; +import { useStoryStore } from '../../../../modules/stories/application/storyStore'; +import { getMediaUrl } from '../../../../lib/utils'; +import { getCroppedImg } from '../../../../lib/imageCrop'; +import DatePicker from '../../../../components/DatePicker'; +import { useChatStore } from '../../../chats/application/chatStore'; +import { toggleMuteChat, isChatMuted } from '../../../../lib/sounds'; interface UserProfileProps { userId: string; @@ -49,7 +52,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is try { let targetChatId = personalChat?.id; if (!targetChatId) { - const chat = await api.createPersonalChat(userId); + const chat = await ChatApi.createPersonalChat(userId); addChat(chat); const socket = getSocket(); if (socket) socket.emit('join_chat', chat.id); @@ -167,7 +170,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is useEffect(() => { loadProfile(); if (!isSelf) { - api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {}); + FriendApi.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {}); } }, [userId, isSelf]); @@ -177,10 +180,10 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is setTabLoading(true); try { if (tab === 'publications') { - const data = await api.getUserStories(userId); + const data = await StoryApi.getUserStories(userId); setUserStories(data.stories || []); } 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); else if (tab === 'gifs') setSharedGifs(data); else if (tab === 'files') setSharedFiles(data); @@ -211,7 +214,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is setBio(authUser.bio || ''); setBirthday(authUser.birthday || ''); } else { - const data = await api.getUser(userId); + const data = await UserApi.getUser(userId); setProfile(data); if (isSelf) { setDisplayName(data.displayName || ''); @@ -230,7 +233,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is try { setIsSaving(true); const dateToSave = birthday ? new Date(birthday).toISOString() : undefined; - const updated = await api.updateProfile({ + const updated = await UserApi.updateProfile({ displayName: displayName.trim(), bio: bio.trim(), birthday: dateToSave, @@ -248,7 +251,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is const handleSendFriendRequest = async () => { try { setFriendLoading(true); - const result = await api.sendFriendRequest(userId); + const result = await FriendApi.sendFriendRequest(userId); if (result.status === 'accepted') { setFriendStatus({ status: 'accepted', friendshipId: null }); } else { @@ -268,7 +271,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is if (!friendStatus?.friendshipId) return; try { setFriendLoading(true); - await api.acceptFriendRequest(friendStatus.friendshipId); + await FriendApi.acceptFriendRequest(friendStatus.friendshipId); setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId }); const socket = getSocket(); 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; try { setFriendLoading(true); - await api.removeFriend(friendStatus.friendshipId); + await FriendApi.removeFriend(friendStatus.friendshipId); setFriendStatus({ status: 'none', friendshipId: null }); const socket = getSocket(); 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); if (!croppedFile) throw new Error("Could not crop image"); - const updatedUser = await api.uploadAvatar(croppedFile); + const updatedUser = await UserApi.uploadAvatar(croppedFile); setProfile(updatedUser); useAuthStore.getState().updateUser(updatedUser); setIsCropping(false); @@ -332,7 +335,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is const handleRemoveAvatar = async () => { try { setTabLoading(true); - await api.removeAvatar(); + await UserApi.removeAvatar(); const updatedUser = { ...profile!, avatar: null }; setProfile(updatedUser); useAuthStore.getState().updateUser({ avatar: null }); diff --git a/apps/web/src/pages/AuthPage.tsx b/apps/web/src/pages/AuthPage.tsx deleted file mode 100644 index f68f6b3..0000000 --- a/apps/web/src/pages/AuthPage.tsx +++ /dev/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 ( - - {/* Переключатель языка сверху по центру */} -
- -
- -
- - {/* Карточка авторизации */} - -
- - {/* Заголовок */} -
- - - -

Knot Messenger

-

- {isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')} -

-
- - {/* Ошибка */} - - {error && ( - - {error} - - )} - - - {/* Форма */} -
-
- - 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" - /> -
- - - {!isLogin && ( - -
- - 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]" - /> -
-
- )} -
- -
- -
- 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} - /> - -
- {!isLogin && ( -

-

- {lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'} -

- )} -
- - - {!isLogin && ( - -
- - 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]" - /> -
-
- )} -
- - - {isSubmitting ? ( -
- ) : ( - <> - {isLogin ? (lang === 'ru' ? 'Войти' : 'Login') : (lang === 'ru' ? 'Создать аккаунт' : 'Create account')} - - - )} - - - - {/* Футер с переключателем */} - {enableRegistration && ( -
-

- {isLogin ? (lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?") : (lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?')} -

- -
- )} - -
- - - ); -}