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