From 2ab5b295e8a805c1197458caca5c26ecd67b7868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 3 Apr 2026 16:57:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=B5=D1=80=D0=B3=D0=B0=D0=BD=D0=B0=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/DTOs/UserProfileDto.cs | 1 + .../Profiles/UpdateProfile/UpdateProfile.cs | 1 + .../Database/ProfileRepository.cs | 2 +- .../Mappings/ProfileMappings.cs | 1 + client-web/src/core/infrastructure/i18n.ts | 6 + .../presentation/layouts/GlobalNavBar.tsx | 5 +- .../modules/chats/presentation/ChatPage.tsx | 3 + .../presentation/components/ChatListItem.tsx | 9 +- .../presentation/components/ChatView.tsx | 323 +++++++++++++----- .../modules/users/infrastructure/userApi.ts | 2 +- .../components/AvatarCropModal.tsx | 113 ++++++ .../presentation/components/SettingsPage.tsx | 320 +++++++++++++++++ 12 files changed, 688 insertions(+), 98 deletions(-) create mode 100644 client-web/src/modules/users/presentation/components/AvatarCropModal.tsx create mode 100644 client-web/src/modules/users/presentation/components/SettingsPage.tsx diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs index cf4a731..1dbb0fa 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs @@ -10,6 +10,7 @@ public class UserProfileDto public string? Avatar { get; set; } public bool IsBot { get; set; } public DateTime? LastSeen { get; set; } + public DateTime? Birthday { get; set; } public bool IsPremium { get; set; } public DateTime CreatedAt { get; set; } } diff --git a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs index 396c466..80184cf 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs @@ -30,6 +30,7 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler p.Id == dto.UserId, profile, new ReplaceOptions { IsUpsert = true }, ct); return Result.Success(profile.ToDto()); diff --git a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs index 73a98e8..01d8be8 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs @@ -16,6 +16,7 @@ public static class ProfileMappings Avatar = document.AvatarUrl, IsBot = false, LastSeen = null, + Birthday = document.Birthday, IsPremium = false, CreatedAt = document.CreatedAt }; diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index 4ef4a3f..c7f3b91 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -23,6 +23,9 @@ const translations = { enterName: 'Введите имя', tellAboutYourself: 'Расскажите о себе', removePhoto: 'Удалить фото', + cropPhoto: 'Обрезать фото', + saveChanges: 'Сохранить изменения', + displayName: 'Отображаемое имя', // Settings language: 'Язык / Language', interfaceLang: 'Язык интерфейса', @@ -330,6 +333,9 @@ const translations = { enterName: 'Enter name', tellAboutYourself: 'Tell about yourself', removePhoto: 'Remove photo', + cropPhoto: 'Crop Photo', + saveChanges: 'Save Changes', + displayName: 'Display Name', language: 'Language', chats: 'Chats', calls: 'Calls', diff --git a/client-web/src/core/presentation/layouts/GlobalNavBar.tsx b/client-web/src/core/presentation/layouts/GlobalNavBar.tsx index 76ee18b..0c9c8e1 100644 --- a/client-web/src/core/presentation/layouts/GlobalNavBar.tsx +++ b/client-web/src/core/presentation/layouts/GlobalNavBar.tsx @@ -44,7 +44,10 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro ))} -
+
onTabChange('settings')} + >
User Profile
+ ) : activeTab === 'settings' ? ( + ) : (

Coming soon

diff --git a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx index f591a02..eb12ac7 100644 --- a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -89,8 +89,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { const proceedWithClick = () => { setShowAttachmentConfirm(false); (window as any).hasUnsavedAttachments = false; - setActiveChat(chat.id); - loadMessages(chat.id); + + if (isActive) { + window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } })); + } else { + setActiveChat(chat.id); + loadMessages(chat.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index 40ea184..b7446de 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -117,6 +117,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const lastObservedMessageIdRef = useRef(null); const initialScrollChatId = useRef(null); + const chatScrollPositionsRef = useRef>({}); + const visitedChatsRef = useRef>(new Set()); // Load muted state useEffect(() => { @@ -177,75 +179,234 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal // Прокрутка вниз const scrollToBottom = useCallback((smooth = true) => { - messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' }); + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' }); + } else if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + } }, []); - // Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки) - useLayoutEffect(() => { - setScrollReady(false); - }, [activeChat]); + const isInitializingRef = useRef(false); + const isScrollingToBottomRef = useRef(false); + const scrollTimeoutRef = useRef(null); + const prevChatIdRef = useRef(activeChat); - useLayoutEffect(() => { - if (!isLoadingMessages && messagesContainerRef.current && activeChat !== initialScrollChatId.current) { - initialScrollChatId.current = activeChat; - const container = messagesContainerRef.current; - const unreadId = sessionUnreadRef.current.msgId; + // 1. СОХРАНЕНИЕ ПОЗИЦИИ (СТРОГО ПО ID) + const saveScrollPosition = useCallback((targetChatId?: string) => { + const container = messagesContainerRef.current; + const chatId = targetChatId || activeChat; + + // НЕ сохраняем, если чат ещё не восстановил свою позицию или в процессе загрузки + if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return; - if (unreadId && activeChat === sessionUnreadRef.current.chatId) { - const dividerEl = document.getElementById('unread-divider'); - const unreadEl = document.getElementById(`msg-${unreadId}`); - const targetEl = dividerEl || unreadEl; - - if (targetEl) { - // Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой - container.scrollTop = targetEl.offsetTop - 60; - } else { - container.scrollTop = container.scrollHeight; - requestAnimationFrame(() => { - if (container) container.scrollTop = container.scrollHeight; - }); - setTimeout(() => { - if (container) container.scrollTop = container.scrollHeight; - }, 100); + // ГАРАНТИЯ: Если сообщения в стейте НЕ от этого чата - не пишем в память мусор + if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return; + + // КРИТИЧНО: Если этот чат уже помечен как находящийся внизу, + // не позволяем автоматике перезаписать это якорем (защита "второго клика") + if (localStorage.getItem(`chat_at_bottom_${chatId}`) === 'true') { + const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150; + if (isNearBottomNow) { + localStorage.removeItem(`chat_anchor_${chatId}`); + return; } - } else { - container.scrollTop = container.scrollHeight; - requestAnimationFrame(() => { - if (container) container.scrollTop = container.scrollHeight; - }); - setTimeout(() => { - if (container) container.scrollTop = container.scrollHeight; - }, 100); - } - setScrollReady(true); } - }, [activeChat, isLoadingMessages]); - // Scroll on new message arrivals - useEffect(() => { - if (chatMessages.length > 0) { - const lastMsg = chatMessages[chatMessages.length - 1]; - const prevId = lastObservedMessageIdRef.current; - lastObservedMessageIdRef.current = lastMsg.id; + const messageElements = container.querySelectorAll('[data-message-id]'); + if (messageElements.length === 0) return; - // Scroll ONLY if a genuinely new message was added (not during initial chat load) - if (prevId && prevId !== lastMsg.id) { - if (lastMsg.senderId === user?.id) { - setTimeout(() => scrollToBottom(true), 50); - } else { - // Если пользователь внизу — прокрутить - const container = messagesContainerRef.current; - if (container) { - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 300; - if (isNearBottom) setTimeout(() => scrollToBottom(true), 50); - } - } + let anchor = null; + const containerRect = container.getBoundingClientRect(); + + for (const el of messageElements) { + const rect = el.getBoundingClientRect(); + if (rect.top >= containerRect.top) { + anchor = { + id: el.getAttribute('data-message-id'), + offset: rect.top - containerRect.top + }; + break; } + } + + if (anchor && anchor.id) { + localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor)); + } + + const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150; + if (isAtBottomNow) { + localStorage.setItem(`chat_at_bottom_${chatId}`, 'true'); + localStorage.removeItem(`chat_anchor_${chatId}`); } else { - lastObservedMessageIdRef.current = null; + localStorage.removeItem(`chat_at_bottom_${chatId}`); } - }, [chatMessages.length, user?.id, scrollToBottom]); + }, [activeChat, scrollReady, chatMessages]); + + // 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ + const restoreScrollPosition = useCallback(() => { + const container = messagesContainerRef.current; + if (!container || !activeChat) return false; + + // КРИТИЧНО: Ждем, пока в хранилище сообщений появятся данные именно от активного чата + if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) { + return false; + } + + if (chatMessages.length === 0) return true; + + // Сначала проверяем, был ли пользователь внизу (защита "второго клика") + if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') { + scrollToBottom(false); + + // Агрессивные повторы, если контент еще догружается (картинки и т.д.) + for (const delay of [100, 300, 600, 1000]) { + setTimeout(() => { + if (activeChat === prevChatIdRef.current) scrollToBottom(false); + }, delay); + } + return true; + } + + // Восстановление по якорю сообщения + const saved = localStorage.getItem(`chat_anchor_${activeChat}`); + if (saved) { + try { + const { id, offset } = JSON.parse(saved); + let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement; + if (!el) { + const msgIndex = chatMessages.findIndex(m => m.id === id); + if (msgIndex !== -1) { + for (let i = msgIndex; i < chatMessages.length; i++) { + const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement; + if (nextEl) { el = nextEl; break; } + } + } + } + if (el) { + container.scrollTop = el.offsetTop - offset; + return true; + } + if (chatMessages.some(m => m.id === id)) return false; + } catch (e) { console.error(e); } + } + + // Если ничего нет - к непрочитанным или вниз + const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id)); + if (firstUnread) { + const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider'); + if (el) { + container.scrollTop = (el as HTMLElement).offsetTop - 80; + return true; + } + } + scrollToBottom(false); + return true; + }, [activeChat, chatMessages, scrollToBottom, user?.id]); + + // 3. ОБЗЕРВЕР И ИНИЦИАЛИЗАЦИЯ + useEffect(() => { + if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return; + const container = messagesContainerRef.current; + + const observer = new ResizeObserver(() => { + // Игнорируем замеры, пока мы не отпозиционировали чат изначально + if (isInitializingRef.current) return; + + if (!scrollReady) { + if (restoreScrollPosition()) { + setScrollReady(true); + isInitializingRef.current = false; + } + } else if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') { + const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 300; + if (isNearBottomNow) { + scrollToBottom(true); + } + } + }); + + const messagesDiv = container.querySelector('.space-y-1'); + if (messagesDiv) observer.observe(messagesDiv); + + // Принудительно пробуем восстановить, если сообщения ПРАВИЛЬНЫЕ + if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) { + if (restoreScrollPosition()) { + setScrollReady(true); + isInitializingRef.current = false; + } + } + + return () => observer.disconnect(); + }, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady, scrollToBottom]); + + // 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (ФИКС RACE CONDITION) + useLayoutEffect(() => { + if (activeChat !== prevChatIdRef.current) { + // СРАЗУ блокируем сохранение, чтобы handleScroll ничего не записал при изменении высоты + isInitializingRef.current = true; + + // Сохраняем позицию ПРЕДЫДУЩЕГО чата ПЕРЕД установкой новых данных + if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) { + saveScrollPosition(prevChatIdRef.current); + } + + setScrollReady(false); + prevChatIdRef.current = activeChat; + + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = 0; + } + + // Таймер защиты на случай медленного рендера + const timer = setTimeout(() => { + isInitializingRef.current = false; + }, 1000); + + return () => clearTimeout(timer); + } + }, [activeChat, saveScrollPosition, scrollReady]); + + const checkScrollPosition = useCallback(() => { + const container = messagesContainerRef.current; + if (!container || isInitializingRef.current) return; + const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300; + setShowScrollDown(!isNearBottom); + }, []); + + const handleScroll = () => { + // В период инициализации (1.2с) или принудительного скролла вниз - игнорируем любые события. + if (isInitializingRef.current || isScrollingToBottomRef.current) return; + + checkScrollPosition(); + const container = messagesContainerRef.current; + if (container && activeChat) { + const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 120; + if (!isNearBottomNow) localStorage.removeItem(`chat_at_bottom_${activeChat}`); + + if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 200); + + if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) { + useChatStore.getState().loadMessages(activeChat, false, true); + } + } + }; + + useEffect(() => { + const handleScrollEvent = (e: any) => { + if (e.detail?.chatId === activeChat) { + isScrollingToBottomRef.current = true; + localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true'); + localStorage.removeItem(`chat_anchor_${activeChat}`); + scrollToBottom(true); + setTimeout(() => { + isScrollingToBottomRef.current = false; + }, 500); + } + }; + window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent); + return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent); + }, [activeChat, scrollToBottom]); // Read receipts using IntersectionObserver const sentReadIdsRef = useRef>(new Set()); @@ -254,7 +415,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal useEffect(() => { if (!activeChat || !user?.id) return; - // Cleanup previous observer if (observerRef.current) observerRef.current.disconnect(); sentReadIdsRef.current.clear(); @@ -286,7 +446,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }); if (newlyReadIds.length > 0 && highestMsgId) { - console.log('[IntersectionObserver] Marking as read up to:', highestSequenceId); socket.emit('read_messages', { chatId: activeChat, lastReadMessageId: highestMsgId, @@ -297,13 +456,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }, { root: messagesContainerRef.current, - threshold: 0.1, // Message must be 10% visible + threshold: 0.1, } ); observerRef.current = observer; - // Initial observation of unread messages const observeUnread = () => { if (!messagesContainerRef.current) return; const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); @@ -315,16 +473,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }); }; - // Delay slight to let everything mount and be visible in DOM setTimeout(observeUnread, 100); - return () => observer.disconnect(); }, [activeChat, user?.id]); - // Re-run observation when messages change (to catch new arrivals) useEffect(() => { - if (observerRef.current) { - if (!messagesContainerRef.current) return; + if (observerRef.current && messagesContainerRef.current) { const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); unreadElements.forEach((el) => { const id = el.getAttribute('data-message-id'); @@ -335,26 +489,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } }, [chatMessages, scrollReady]); - // Scroll detection - const checkScrollPosition = useCallback(() => { - const container = messagesContainerRef.current; - if (!container) return; - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 300; - setShowScrollDown(!isNearBottom); - }, []); - - const handleScroll = () => { - checkScrollPosition(); - - const container = messagesContainerRef.current; - if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) { - useChatStore.getState().loadMessages(activeChat, false, true); - } - }; - useEffect(() => { - // Check scroll position when messages change or scroll ready state changes if (scrollReady) { checkScrollPosition(); } @@ -998,23 +1133,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal {showScrollDown && ( { - scrollToBottom(false); + scrollToBottom(true); if (activeChat && unreadCount > 0) { useChatStore.getState().markAllAsRead(activeChat); } }} - className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10" + className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-gradient-to-br from-primary to-primary-container text-on-primary-container shadow-[0_8px_30px_rgba(48,150,229,0.3)] flex items-center justify-center transition-all z-10 border border-white/10 backdrop-blur-md" > - + arrow_downward {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} diff --git a/client-web/src/modules/users/infrastructure/userApi.ts b/client-web/src/modules/users/infrastructure/userApi.ts index f046d66..405a7f8 100644 --- a/client-web/src/modules/users/infrastructure/userApi.ts +++ b/client-web/src/modules/users/infrastructure/userApi.ts @@ -12,7 +12,7 @@ export class UserApi { return httpClient.request(`/profiles/${id}`); } - static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) { + static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) { // Конечная точка в новом бэкенде: PUT /api/profiles/profile return httpClient.request('/profiles/profile', { method: 'PUT', diff --git a/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx b/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx new file mode 100644 index 0000000..119125f --- /dev/null +++ b/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx @@ -0,0 +1,113 @@ +import { useState, useCallback } from 'react'; +import Cropper, { Area, Point } from 'react-easy-crop'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Crop, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; +import { useLang } from '../../../../core/infrastructure/i18n'; + +interface AvatarCropModalProps { + image: string; + onCrop: (cropData: Area) => void; + onClose: () => void; +} + +export default function AvatarCropModal({ image, onCrop, onClose }: AvatarCropModalProps) { + const { t } = useLang(); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }, []); + + const handleSave = () => { + if (croppedAreaPixels) { + onCrop(croppedAreaPixels); + } + }; + + return ( + +
+ + e.stopPropagation()} + > + {/* Header */} +
+ + {t('cropPhoto') || 'ОБРЕЗКА ФОТО'} + + +
+ + {/* Cropper Area */} +
+ +
+ + {/* Controls */} +
+
+ + setZoom(Number(e.target.value))} + className="flex-1 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer accent-primary" + /> + +
+ +
+ + +
+
+
+ + ); +} diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx new file mode 100644 index 0000000..a106e55 --- /dev/null +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -0,0 +1,320 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + User, Camera, Edit3, Calendar, Info, + MapPin, AtSign, Check, Loader2, LogOut, + ChevronRight, ArrowLeft, Languages, Palette, Trash2, + Bell, Shield, Eye +} from 'lucide-react'; +import { useAuthStore } from '../../../auth/application/authStore'; +import { useLang } from '../../../../core/infrastructure/i18n'; +import { UserApi } from '../../infrastructure/userApi'; +import { getMediaUrl, getInitials } from '../../../../core/utils/utils'; +import DatePicker from '../../../../core/presentation/components/ui/DatePicker'; +import AvatarCropModal from './AvatarCropModal'; +import { Area } from 'react-easy-crop'; + +export default function SettingsPage() { + const { user, updateUser, logout } = useAuthStore(); + const { t, lang, setLang } = useLang(); + + const [editing, setEditing] = useState(false); + const [formData, setFormData] = useState({ + displayName: user?.displayName || '', + bio: user?.bio || '', + birthday: user?.birthday || '' + }); + const [saving, setSaving] = useState(false); + + // Avatar cropping state + const [cropModal, setCropModal] = useState<{ open: boolean, image: string, file: File | null }>({ open: false, image: '', file: null }); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (user) { + setFormData({ + displayName: user.displayName || '', + bio: user.bio || '', + birthday: user.birthday || '' + }); + } + }, [user]); + + const handleSaveProfile = async () => { + setSaving(true); + try { + const payload = { + ...formData, + birthday: formData.birthday || null + }; + const updatedUser = await UserApi.updateProfile(payload); + updateUser(updatedUser); + setEditing(false); + } catch (err) { + console.error(err); + } finally { + setSaving(false); + } + }; + + const handleAvatarChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = () => { + setCropModal({ open: true, image: reader.result as string, file }); + }; + reader.readAsDataURL(file); + } + }; + + const onCropConfirm = async (cropData: Area) => { + if (!cropModal.file) return; + setCropModal(prev => ({ ...prev, open: false })); + setUploadingAvatar(true); + try { + const updatedUser = await UserApi.cropAvatar(cropModal.file, { + x: Math.round(cropData.x), + y: Math.round(cropData.y), + width: Math.round(cropData.width), + height: Math.round(cropData.height) + }); + updateUser(updatedUser); + } catch (err) { + console.error(err); + } finally { + setUploadingAvatar(false); + } + }; + + const removeAvatar = async () => { + try { + const updatedUser = await UserApi.removeAvatar(); + updateUser(updatedUser); + } catch (err) { + console.error(err); + } + } + + const initials = getInitials(user?.displayName || user?.username || '??'); + + return ( +
+
+ {/* Header Section */} +
+
+

+ {t('settings') || 'Settings'} +

+

Управляйте своим профилем и настройками приложения

+
+ {!editing ? ( + + ) : ( +
+ + +
+ )} +
+ + {/* Profile/Avatar Section */} +
+
+ {/* Avatar Glow */} +
+ +
+
+ {user?.avatar ? ( + + ) : ( +
+ {initials} +
+ )} + + {uploadingAvatar && ( +
+ +
+ )} + + {/* Overlay on hover */} +
+ + {user?.avatar && ( + + )} +
+
+
+ +
+ +
+
+ + {editing ? ( + setFormData(p => ({ ...p, displayName: e.target.value }))} + className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-lg font-bold text-white focus:border-primary/50 transition-all outline-none" + placeholder="Как вас называть?" + /> + ) : ( +

{user?.displayName}

+ )} +
+ +
+
+ + +
+

@{user?.username || user?.userName}

+
+
+
+ + {/* Bio & Details Section */} +
+
+
+ + {t('about') || 'О себе'} +
+ {editing ? ( +