pre-deep-ddd-refactor

This commit is contained in:
Халимов Рустам
2026-03-19 22:07:23 +03:00
parent abd94d6154
commit fb252f9d87
34 changed files with 1152 additions and 975 deletions

View File

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

View File

@@ -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<RTCConfiguration> {
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 };

View File

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

View File

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

View File

@@ -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<RTCConfiguration> {
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();

View File

@@ -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<FriendWithId[]>([]);
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([]);
const [friendsLoading, setFriendsLoading] = useState(false);
const [friendSearch, setFriendSearch] = useState('');
const [friendSearchResults, setFriendSearchResults] = useState<UserPresence[]>([]);
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 = () => (
<motion.div key="friends" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
<button onClick={() => { changeView('main'); setFriendSearch(''); setFriendSearchResults([]); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
<button onClick={() => { changeView('main'); clearSearch(); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
<ArrowLeft size={20} />
</button>
<h3 className="text-sm font-semibold text-white flex-1">{t('friends')}</h3>

View File

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

View File

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

View File

@@ -1,418 +0,0 @@
import type { User, UserBasic, UserPresence, Chat, Message, MediaItem, StoryGroup, FriendRequest, FriendWithId, FriendshipStatus } from './types';
const API_BASE = '/api';
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
private async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
const { timeout = 30_000, ...fetchOptions } = options;
const controller = new AbortController();
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
const isFormData = fetchOptions.body instanceof FormData;
const computedHeaders: Record<string, string> = {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...(fetchOptions.headers as Record<string, string>),
};
if (!isFormData && !computedHeaders['Content-Type']) {
computedHeaders['Content-Type'] = 'application/json';
}
let response: Response;
try {
response = await fetch(`${API_BASE}${endpoint}`, {
...fetchOptions,
headers: computedHeaders,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Время ожидания запроса истекло');
}
throw err;
}
clearTimeout(timer);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
throw new Error(errorMessage);
}
return response.json();
}
// \u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f
async login(username: string, password: string) {
return this.request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
async register(username: string, displayName: string, password: string, bio?: string) {
return this.request<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
}
async getMe() {
return this.request<{ user: User }>('/auth/me');
}
async getConfig() {
return this.request<any>('/config');
}
// \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438
async searchUsers(query: string) {
return this.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
}
async getUser(id: string) {
return this.request<User>(`/users/${id}`);
}
async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return this.request<User>('/users/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/users/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('Ошибка загрузки аватара');
return response.json() as Promise<User>;
}
async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('cropX', cropData.x.toString());
formData.append('cropY', cropData.y.toString());
formData.append('cropWidth', cropData.width.toString());
formData.append('cropHeight', cropData.height.toString());
const response = await fetch(`${API_BASE}/users/avatar/crop`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка кропа аватара');
return response.json() as Promise<User>;
}
async removeAvatar() {
return this.request<User>('/users/avatar', { method: 'DELETE' });
}
async searchMessages(query: string, chatId?: string) {
const params = new URLSearchParams({ q: query });
if (chatId) params.append('chatId', chatId);
return this.request<Message[]>(`/messages/search?${params}`);
}
// \u0427\u0430\u0442\u044b
async getChats() {
return this.request<Chat[]>('/chats');
}
async createPersonalChat(userId: string) {
return this.request<Chat>('/chats/personal', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
async createGroupChat(name: string, memberIds: string[]) {
return this.request<Chat>('/chats/group', {
method: 'POST',
body: JSON.stringify({ name, memberIds }),
});
}
// \u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f
async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return this.request<Message[]>(`/messages/chat/${chatId}${params}`);
}
async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/messages/upload`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0430\u0439\u043b\u0430');
return response.json() as Promise<{ url: string; filename: string; size: number }>;
}
// \u0413\u0440\u0443\u043f\u043f\u044b
async updateGroup(chatId: string, data: { name?: string; description?: string }) {
return this.request<Chat>(`/chats/${chatId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadGroupAvatar(chatId: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0430\u0432\u0430\u0442\u0430\u0440\u0430');
return response.json() as Promise<Chat>;
}
async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('x', cropData.x.toString());
formData.append('y', cropData.y.toString());
formData.append('width', cropData.width.toString());
formData.append('height', cropData.height.toString());
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar/crop`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка кропа аватара');
return response.json() as Promise<Chat>;
}
async removeGroupAvatar(chatId: string) {
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
}
async addGroupMembers(chatId: string, userIds: string[]) {
return this.request<Chat>(`/chats/${chatId}/members`, {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
async removeGroupMember(chatId: string, userId: string) {
return this.request<Chat>(`/chats/${chatId}/members/${userId}`, {
method: 'DELETE',
});
}
async clearChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
}
async deleteChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
}
async togglePinChat(chatId: string) {
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return this.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
// Stories
async getStories() {
return this.request<StoryGroup[]>('/stories');
}
async getUserStories(userId: string) {
return this.request<StoryGroup>(`/stories/user/${userId}`);
}
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
return this.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async uploadVideoToStory(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/stories/video`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
});
if (!response.ok) throw new Error('Ошибка загрузки видео истории');
return response.json() as Promise<{ url: string }>;
}
async viewStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
}
async deleteStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
}
async getStoryViewers(storyId: string) {
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
}
async addStoryReaction(storyId: string, emoji: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
async removeStoryReaction(storyId: string, emoji: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'DELETE',
body: JSON.stringify({ emoji }),
});
}
async addStoryReply(storyId: string, content: string) {
return this.request<{ message: string }>(`/stories/${storyId}/reply`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async getStoryReplies(storyId: string) {
return this.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
}
// Favorites chat
async getOrCreateFavorites() {
return this.request<Chat>('/chats/favorites', { method: 'POST' });
}
// User settings
async updateSettings(settings: any) {
const res = await this.request('/users/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
return res;
}
async analyzeTelegramImport(file: File) {
const formData = new FormData();
formData.append('file', file);
const res = await this.request('/import/telegram/analyze', {
method: 'POST',
body: formData,
});
return res;
}
async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
return this.request('/import/telegram/execute', {
method: 'POST',
body: JSON.stringify(req),
});
}
// Friends
async getFriends() {
return this.request<FriendWithId[]>('/friends');
}
async getFriendRequests() {
return this.request<FriendRequest[]>('/friends/requests');
}
async getOutgoingRequests() {
return this.request<FriendRequest[]>('/friends/outgoing');
}
async getFriendshipStatus(userId: string) {
return this.request<FriendshipStatus>(`/friends/status/${userId}`);
}
async sendFriendRequest(friendId: string) {
return this.request<{ status: string }>('/friends/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
});
}
async acceptFriendRequest(friendshipId: string) {
return this.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
}
async declineFriendRequest(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
}
async removeFriend(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
}
async getIceServers() {
return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
}
// Klipy
async getTrendingGifs() {
return this.request<any>('/klipy/trending');
}
async searchKlipyGifs(query: string) {
return this.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
}
}
export const api = new ApiClient();

View File

@@ -0,0 +1,27 @@
import { httpClient } from './httpClient';
export class AppApi {
static async analyzeTelegramImport(file: File) {
const formData = new FormData();
formData.append('file', file);
return httpClient.request('/import/telegram/analyze', {
method: 'POST',
body: formData,
});
}
static async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
return httpClient.request('/import/telegram/execute', {
method: 'POST',
body: JSON.stringify(req),
});
}
static async getTrendingGifs() {
return httpClient.request<any>('/klipy/trending');
}
static async searchKlipyGifs(query: string) {
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
}
}

View File

@@ -0,0 +1,51 @@
const API_BASE = '/api';
export class HttpClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
const { timeout = 30_000, ...fetchOptions } = options;
const controller = new AbortController();
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
const isFormData = fetchOptions.body instanceof FormData;
const computedHeaders: Record<string, string> = {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...(fetchOptions.headers as Record<string, string>),
};
if (!isFormData && !computedHeaders['Content-Type']) {
computedHeaders['Content-Type'] = 'application/json';
}
let response: Response;
try {
response = await fetch(`${API_BASE}${endpoint}`, {
...fetchOptions,
headers: computedHeaders,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Время ожидания запроса истекло');
}
throw err;
}
clearTimeout(timer);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
throw new Error(errorMessage);
}
return response.json();
}
}
export const httpClient = new HttpClient();

View File

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

View File

@@ -0,0 +1,11 @@
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterCredentials {
username: string;
displayName: string;
password: string;
bio?: string;
}

View File

@@ -0,0 +1,30 @@
import { httpClient } from '../../../lib/httpClient';
import type { User } from '../../../lib/types';
export class AuthApi {
static async login(username: string, password: string) {
return httpClient.request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
static async register(username: string, displayName: string, password: string, bio?: string) {
return httpClient.request<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
}
static async getMe() {
return httpClient.request<{ user: User }>('/auth/me');
}
static async getConfig() {
return httpClient.request<any>('/config');
}
static setToken(token: string | null) {
httpClient.setToken(token);
}
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLang } from '../../../lib/i18n';
import { MessageSquare } from 'lucide-react';
import LoginForm from './components/LoginForm';
import RegisterForm from './components/RegisterForm';
import { AuthApi } from '../infrastructure/authApi';
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
const { lang, setLang } = useLang();
const [enableRegistration, setEnableRegistration] = useState(true);
useEffect(() => {
AuthApi.getConfig()
.then(data => {
if (data && typeof data.enableRegistration === 'boolean') {
setEnableRegistration(data.enableRegistration);
if (!data.enableRegistration) setIsLogin(true);
}
})
.catch(() => {});
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
>
{/* Переключатель языка сверху по центру */}
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
<div className="w-px h-4 bg-white/10 self-center" />
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
</div>
{/* Карточка авторизации */}
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="relative z-10 w-full max-w-[420px] mx-4"
>
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
{/* Заголовок */}
<div className="flex flex-col items-center mb-10">
<motion.div
initial={{ rotate: -180, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
>
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
</motion.div>
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
</p>
</div>
<AnimatePresence mode="wait">
<motion.div
key={isLogin ? 'login' : 'register'}
initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
transition={{ duration: 0.2 }}
>
{isLogin ? (
<LoginForm
enableRegistration={enableRegistration}
onRegisterClick={() => setIsLogin(false)}
/>
) : (
<RegisterForm
enableRegistration={enableRegistration}
onLoginClick={() => setIsLogin(true)}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
import { useAuthStore } from '../../application/authStore';
import { useLang } from '../../../../lib/i18n';
import type { LoginCredentials } from '../../domain/types';
interface Props {
onRegisterClick?: () => void;
enableRegistration?: boolean;
}
export default function LoginForm({ onRegisterClick, enableRegistration }: Props) {
const [credentials, setCredentials] = useState<LoginCredentials>({ username: '', password: '' });
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuthStore();
const { lang } = useLang();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
await login(credentials.username, credentials.password);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
setIsSubmitting(false);
}
};
return (
<>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
role="alert"
>
{error}
</motion.div>
)}
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Войти' : 'Login'}
<ArrowRight size={18} />
</>
)}
</motion.button>
</form>
{enableRegistration && onRegisterClick && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"}
</p>
<button
onClick={onRegisterClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
type="button"
>
{lang === 'ru' ? 'Зарегистрироваться' : 'Register'}
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
import { useAuthStore } from '../../application/authStore';
import { useLang } from '../../../../lib/i18n';
import type { RegisterCredentials } from '../../domain/types';
interface Props {
onLoginClick: () => void;
enableRegistration?: boolean;
}
export default function RegisterForm({ onLoginClick, enableRegistration }: Props) {
const [credentials, setCredentials] = useState<RegisterCredentials>({ username: '', displayName: '', password: '', bio: '' });
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { register } = useAuthStore();
const { lang } = useLang();
if (!enableRegistration) return null;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
await register(credentials.username, credentials.displayName || credentials.username, credentials.password, credentials.bio);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
setIsSubmitting(false);
}
};
return (
<>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
role="alert"
>
{error}
</motion.div>
)}
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
</label>
<input
type="text"
value={credentials.displayName}
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
<span className="w-1 h-1 rounded-full bg-[#9b66ff]" />
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
</p>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'О себе' : 'About me'}
</label>
<input
type="text"
value={credentials.bio}
onChange={(e) => setCredentials({ ...credentials, bio: e.target.value })}
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
/>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Создать аккаунт' : 'Create account'}
<ArrowRight size={18} />
</>
)}
</motion.button>
</form>
{enableRegistration && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'}
</p>
<button
onClick={onLoginClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
type="button"
>
{lang === 'ru' ? 'Войти' : 'Login'}
</button>
</div>
)}
</>
);
}

View File

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

View File

@@ -0,0 +1,112 @@
import { httpClient } from '../../../lib/httpClient';
import type { Chat, Message } from '../../../lib/types';
export class ChatApi {
static async getChats() {
return httpClient.request<Chat[]>('/chats');
}
static async createPersonalChat(userId: string) {
return httpClient.request<Chat>('/chats/personal', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
static async createGroupChat(name: string, memberIds: string[]) {
return httpClient.request<Chat>('/chats/group', {
method: 'POST',
body: JSON.stringify({ name, memberIds }),
});
}
static async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return httpClient.request<Message[]>(`/messages/chat/${chatId}${params}`);
}
static async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
return httpClient.request<{ url: string; filename: string; size: number }>('/messages/upload', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async updateGroup(chatId: string, data: { name?: string; description?: string }) {
return httpClient.request<Chat>(`/chats/${chatId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
static async uploadGroupAvatar(chatId: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('x', cropData.x.toString());
formData.append('y', cropData.y.toString());
formData.append('width', cropData.width.toString());
formData.append('height', cropData.height.toString());
return httpClient.request<Chat>(`/chats/${chatId}/avatar/crop`, {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async removeGroupAvatar(chatId: string) {
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
}
static async addGroupMembers(chatId: string, userIds: string[]) {
return httpClient.request<Chat>(`/chats/${chatId}/members`, {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
static async removeGroupMember(chatId: string, userId: string) {
return httpClient.request<Chat>(`/chats/${chatId}/members/${userId}`, {
method: 'DELETE',
});
}
static async clearChat(chatId: string) {
return httpClient.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
}
static async deleteChat(chatId: string) {
return httpClient.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
}
static async togglePinChat(chatId: string) {
return httpClient.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
static async searchMessages(query: string, chatId?: string) {
const params = new URLSearchParams({ q: query });
if (chatId) params.append('chatId', chatId);
return httpClient.request<Message[]>(`/messages/search?${params}`);
}
static async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return httpClient.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
static async getOrCreateFavorites() {
return httpClient.request<Chat>('/chats/favorites', { method: 'POST' });
}
}

View File

@@ -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 */ }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,166 @@
import { create } from 'zustand';
import { UserApi } from '../../users/infrastructure/userApi';
import { FriendApi } from '../infrastructure/friendApi';
import type { FriendWithId, FriendRequest, UserPresence } from '../../../lib/types';
import { getSocket } from '../../../lib/socket';
interface FriendState {
friends: FriendWithId[];
friendRequests: FriendRequest[];
isLoading: boolean;
searchQuery: string;
searchResults: UserPresence[];
isSearching: boolean;
setSearchQuery: (query: string) => void;
loadFriends: () => Promise<void>;
acceptRequest: (requestId: string) => Promise<void>;
declineRequest: (requestId: string) => Promise<void>;
removeFriend: (friendshipId: string) => Promise<void>;
sendRequest: (friendId: string) => Promise<void>;
searchFriends: (query: string, currentUserId?: string) => Promise<void>;
clearSearch: () => void;
initializeSocketEvents: () => () => void;
}
export const useFriendStore = create<FriendState>((set, get) => ({
friends: [],
friendRequests: [],
isLoading: false,
searchQuery: '',
searchResults: [],
isSearching: false,
setSearchQuery: (query) => set({ searchQuery: query }),
loadFriends: async () => {
set({ isLoading: true });
try {
const [friendsList, requests] = await Promise.all([
FriendApi.getFriends(),
FriendApi.getFriendRequests(),
]);
set({ friends: friendsList, friendRequests: requests });
} catch (e) {
console.error('Load friends error:', e);
} finally {
set({ isLoading: false });
}
},
acceptRequest: async (requestId) => {
try {
await FriendApi.acceptFriendRequest(requestId);
const req = get().friendRequests.find(r => r.id === requestId);
if (req) {
const socket = getSocket();
if (socket) socket.emit('friend_accepted', { friendId: req.user.id });
}
await get().loadFriends();
} catch (e) {
console.error(e);
}
},
declineRequest: async (requestId) => {
try {
await FriendApi.declineFriendRequest(requestId);
set((state) => ({
friendRequests: state.friendRequests.filter(r => r.id !== requestId)
}));
} catch (e) {
console.error(e);
}
},
removeFriend: async (friendshipId) => {
try {
const friend = get().friends.find(f => f.friendshipId === friendshipId);
await FriendApi.removeFriend(friendshipId);
if (friend) {
const socket = getSocket();
if (socket) socket.emit('friend_removed', { friendId: friend.id });
}
set((state) => ({
friends: state.friends.filter(f => f.friendshipId !== friendshipId)
}));
} catch (e) {
console.error(e);
}
},
sendRequest: async (friendId) => {
try {
const result = await FriendApi.sendFriendRequest(friendId);
const socket = getSocket();
if (socket) socket.emit('friend_request', { friendId });
if (result.status === 'accepted') {
await get().loadFriends();
}
set((state) => ({
searchResults: state.searchResults.filter(u => u.id !== friendId)
}));
} catch (e) {
console.error(e);
}
},
searchFriends: async (query, currentUserId) => {
const raw = query.trim();
const q = raw.startsWith('@') ? raw.slice(1) : raw;
if (q.length < 3) {
set({ searchResults: [] });
return;
}
set({ isSearching: true });
try {
const results = await UserApi.searchUsers(q);
const { friends } = get();
const friendIds = new Set(friends.map(f => f.id));
set({
searchResults: results.filter(u => u.id !== currentUserId && !friendIds.has(u.id))
});
} catch (e) {
console.error(e);
} finally {
set({ isSearching: false });
}
},
clearSearch: () => set({ searchQuery: '', searchResults: [] }),
initializeSocketEvents: () => {
const socket = getSocket();
if (!socket) return () => {};
const onFriendRequestReceived = () => {
FriendApi.getFriendRequests()
.then(reqs => set({ friendRequests: reqs }))
.catch(() => {});
};
const onFriendRequestAccepted = () => {
get().loadFriends();
};
const onFriendRemoved = (data: { userId: string }) => {
set((state) => ({
friends: state.friends.filter(f => f.id !== data.userId)
}));
};
socket.on('friend_request_received', onFriendRequestReceived);
socket.on('friend_request_accepted', onFriendRequestAccepted);
socket.on('friend_removed', onFriendRemoved);
return () => {
socket.off('friend_request_received', onFriendRequestReceived);
socket.off('friend_request_accepted', onFriendRequestAccepted);
socket.off('friend_removed', onFriendRemoved);
};
}
}));

View File

@@ -0,0 +1,39 @@
import { httpClient } from '../../../lib/httpClient';
import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../lib/types';
export class FriendApi {
static async getFriends() {
return httpClient.request<FriendWithId[]>('/friends');
}
static async getFriendRequests() {
return httpClient.request<FriendRequest[]>('/friends/requests');
}
static async getOutgoingRequests() {
return httpClient.request<FriendRequest[]>('/friends/outgoing');
}
static async getFriendshipStatus(userId: string) {
return httpClient.request<FriendshipStatus>(`/friends/status/${userId}`);
}
static async sendFriendRequest(friendId: string) {
return httpClient.request<{ status: string }>('/friends/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
});
}
static async acceptFriendRequest(friendshipId: string) {
return httpClient.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
}
static async declineFriendRequest(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
}
static async removeFriend(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
}
}

View File

@@ -0,0 +1,66 @@
import { httpClient } from '../../../lib/httpClient';
import type { StoryGroup } from '../../../lib/types';
export class StoryApi {
static async getStories() {
return httpClient.request<StoryGroup[]>('/stories');
}
static async getUserStories(userId: string) {
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
}
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
return httpClient.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
static async uploadVideoToStory(file: File) {
const formData = new FormData();
formData.append('file', file);
return httpClient.request<{ url: string }>('/stories/video', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async viewStory(storyId: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
}
static async deleteStory(storyId: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
}
static async getStoryViewers(storyId: string) {
return httpClient.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
}
static async addStoryReaction(storyId: string, emoji: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
static async removeStoryReaction(storyId: string, emoji: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'DELETE',
body: JSON.stringify({ emoji }),
});
}
static async addStoryReply(storyId: string, content: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reply`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
static async getStoryReplies(storyId: string) {
return httpClient.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
}
}

View File

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

View File

@@ -0,0 +1,59 @@
import { httpClient } from '../../../lib/httpClient';
import type { User, UserPresence } from '../../../lib/types';
export class UserApi {
static async searchUsers(query: string) {
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
}
static async getUser(id: string) {
return httpClient.request<User>(`/users/${id}`);
}
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return httpClient.request<User>('/users/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
static async updateSettings(settings: any) {
return httpClient.request('/users/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
}
static async uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
return httpClient.request<User>('/users/avatar', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('cropX', cropData.x.toString());
formData.append('cropY', cropData.y.toString());
formData.append('cropWidth', cropData.width.toString());
formData.append('cropHeight', cropData.height.toString());
return httpClient.request<User>('/users/avatar/crop', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async removeAvatar() {
return httpClient.request<User>('/users/avatar', { method: 'DELETE' });
}
static async getIceServers() {
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
}
}

View File

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

View File

@@ -1,247 +0,0 @@
import { useState, FormEvent, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import { Eye, EyeOff, ArrowRight, UserPlus, LogIn, MessageSquare } from 'lucide-react';
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [bio, setBio] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login, register } = useAuthStore();
const { t, lang, setLang } = useLang();
const [enableRegistration, setEnableRegistration] = useState(true);
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => {
if (data && typeof data.enableRegistration === 'boolean') {
setEnableRegistration(data.enableRegistration);
if (!data.enableRegistration) setIsLogin(true);
}
})
.catch(() => {});
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
if (isLogin) {
await login(username, password);
} else {
await register(username, displayName || username, password, bio);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
} finally {
setIsSubmitting(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
>
{/* Переключатель языка сверху по центру */}
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
<div className="w-px h-4 bg-white/10 self-center" />
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
</div>
{/* Карточка авторизации */}
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="relative z-10 w-full max-w-[420px] mx-4"
>
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
{/* Заголовок */}
<div className="flex flex-col items-center mb-10">
<motion.div
initial={{ rotate: -180, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
>
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
</motion.div>
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
</p>
</div>
{/* Ошибка */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
role="alert"
>
{error}
</motion.div>
)}
</AnimatePresence>
{/* Форма */}
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username {!isLogin && <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<AnimatePresence>
{!isLogin && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<div className="pt-2">
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
/>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={!isLogin ? "pt-2" : ""}>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-hidden text-[15px]"
required
autoComplete={isLogin ? 'current-password' : 'new-password'}
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{!isLogin && (
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
<div className="w-1 h-1 rounded-full bg-[#9b66ff]" />
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
</p>
)}
</div>
<AnimatePresence>
{!isLogin && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="pt-2">
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'О себе' : 'About me'}
</label>
<input
type="text"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
/>
</div>
</motion.div>
)}
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{isLogin ? (lang === 'ru' ? 'Войти' : 'Login') : (lang === 'ru' ? 'Создать аккаунт' : 'Create account')}
<ArrowRight size={18} />
</>
)}
</motion.button>
</form>
{/* Футер с переключателем */}
{enableRegistration && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{isLogin ? (lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?") : (lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?')}
</p>
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
setPassword('');
setDisplayName('');
setBio('');
}}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors type-button"
type="button"
>
{isLogin ? (lang === 'ru' ? 'Зарегистрироваться' : 'Register') : (lang === 'ru' ? 'Войти' : 'Login')}
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
);
}