Compare commits
12
Commits
abd94d6154
...
473e9bcef6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473e9bcef6 | ||
|
|
a48a0b0977 | ||
|
|
24f029c9ed | ||
|
|
1f8746bd03 | ||
|
|
74cae687a5 | ||
|
|
71667f8948 | ||
|
|
17d02beb30 | ||
|
|
77912f2d88 | ||
|
|
275aa0ee7d | ||
|
|
4384233aa5 | ||
|
|
11ebbc853b | ||
|
|
fb252f9d87 |
@@ -2,9 +2,6 @@ node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
apps/web/dist
|
||||
apps/server/dist
|
||||
apps/server/uploads
|
||||
.vscode
|
||||
.env
|
||||
bin/
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Stage 1: Build React app
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for apps/web
|
||||
# We use the local package files to avoid workspace hoisting issues in Docker
|
||||
COPY apps/web/package.json ./
|
||||
# If there is a lockfile in apps/web, use it, otherwise use root (but root is workspace, so better not)
|
||||
# Let's try to generate a clean install
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY apps/web/ ./
|
||||
|
||||
# Build web frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Custom Nginx config to proxy /api and /socket.io to server:3001
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@ WORKDIR /app
|
||||
|
||||
# Copy everything and restore as distinct layers
|
||||
COPY . .
|
||||
RUN dotnet restore apps/server-net/Knot.sln
|
||||
RUN dotnet restore Knot.sln
|
||||
|
||||
# Build and publish a release
|
||||
RUN dotnet publish apps/server-net/src/Host/Host.csproj -c Release -o out
|
||||
RUN dotnet publish src/Host/Host.csproj -c Release -o out
|
||||
|
||||
# Build runtime image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview
|
||||
+1
-1
@@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[AllowAnonymous]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AdminController : ControllerBase
|
||||
+2
@@ -53,6 +53,8 @@ public sealed class MessagesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10 GB limit for form body
|
||||
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
@@ -174,11 +174,11 @@ using (var scope = app.Services.CreateScope())
|
||||
}
|
||||
|
||||
// Настройка конвейера запросов
|
||||
app.UseCors();
|
||||
|
||||
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
+11
-18
@@ -31,28 +31,25 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
|
||||
var dtos = new List<ChatDto>();
|
||||
bool hasFavorites = false;
|
||||
|
||||
foreach (var chat in userChats)
|
||||
{
|
||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||
|
||||
if (latestMessage == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
userIdsToFetch.Add(member.UserId);
|
||||
}
|
||||
|
||||
if (latestMessage != null)
|
||||
{
|
||||
userIdsToFetch.Add(latestMessage.SenderId);
|
||||
foreach (var r in latestMessage.Reactions)
|
||||
{
|
||||
userIdsToFetch.Add(r.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
@@ -76,6 +73,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>();
|
||||
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
@@ -92,9 +93,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>
|
||||
{
|
||||
new ChatMessageDto(
|
||||
messagesList.Add(new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
@@ -117,8 +116,8 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
||||
)
|
||||
};
|
||||
));
|
||||
}
|
||||
|
||||
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
|
||||
|
||||
@@ -135,12 +134,6 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
));
|
||||
}
|
||||
|
||||
if (!hasFavorites)
|
||||
{
|
||||
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
|
||||
dtos.Add(favs);
|
||||
}
|
||||
|
||||
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
|
||||
return Result.Success(sorted);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user