Прокрутка дерганая

This commit is contained in:
Халимов Рустам
2026-04-03 16:57:06 +03:00
parent 1b8abbc995
commit 2ab5b295e8
12 changed files with 688 additions and 98 deletions

View File

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

View File

@@ -30,6 +30,7 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
profile.About = request.Bio;
profile.Birthday = request.Birthday;
var result = await _repository.UpdateAsync(profile, cancellationToken);
if (result.IsFailure)

View File

@@ -83,7 +83,7 @@ internal class ProfileRepository : IProfileRepository
profile.UpdateProfile(
dto.DisplayName ?? profile.DisplayName,
dto.About ?? profile.Bio,
null);
dto.Birthday);
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, profile, new ReplaceOptions { IsUpsert = true }, ct);
return Result.Success(profile.ToDto());

View File

@@ -16,6 +16,7 @@ public static class ProfileMappings
Avatar = document.AvatarUrl,
IsBot = false,
LastSeen = null,
Birthday = document.Birthday,
IsPremium = false,
CreatedAt = document.CreatedAt
};

View File

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

View File

@@ -44,7 +44,10 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
))}
</div>
<div className="mt-auto group cursor-pointer relative">
<div
className="mt-auto group cursor-pointer relative"
onClick={() => onTabChange('settings')}
>
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
<img
alt="User Profile"

View File

@@ -14,6 +14,7 @@ import ChatView from './components/ChatView';
import CallModal from '../../calls/presentation/components/CallModal';
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
import SettingsPage from '../../users/presentation/components/SettingsPage';
export default function ChatPage() {
const {
@@ -414,6 +415,8 @@ export default function ChatPage() {
</div>
</div>
</div>
) : activeTab === 'settings' ? (
<SettingsPage />
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-zinc-500">Coming soon</p>

View File

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

View File

@@ -117,6 +117,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
const lastObservedMessageIdRef = useRef<string | null>(null);
const initialScrollChatId = useRef<string | null>(null);
const chatScrollPositionsRef = useRef<Record<string, number>>({});
const visitedChatsRef = useRef<Set<string>>(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<any>(null);
const prevChatIdRef = useRef<string | null>(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<Set<string>>(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
<AnimatePresence>
{showScrollDown && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
initial={{ scale: 0.5, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.5, opacity: 0, y: 20 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
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"
>
<ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" />
<span className="material-symbols-outlined text-3xl">arrow_downward</span>
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1.5 rounded-full bg-accent text-white text-[11px] font-bold flex items-center justify-center shadow-lg border-2 border-surface-secondary"
className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest"
>
{unreadCount > 99 ? '99+' : unreadCount}
</motion.span>

View File

@@ -12,7 +12,7 @@ export class UserApi {
return httpClient.request<User>(`/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<User>('/profiles/profile', {
method: 'PUT',

View File

@@ -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<Point>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
const handleSave = () => {
if (croppedAreaPixels) {
onCrop(croppedAreaPixels);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[10001] flex items-center justify-center p-4"
>
<div className="absolute inset-0 bg-black/90 backdrop-blur-xl" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-[500px] aspect-square rounded-[2.5rem] bg-surface-container overflow-hidden border border-white/10 shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="absolute top-0 inset-x-0 h-16 flex items-center justify-between px-6 z-50 bg-linear-to-b from-black/60 to-transparent pointer-events-none">
<span className="text-xs font-black uppercase tracking-[0.2em] text-white/70 pointer-events-auto">
{t('cropPhoto') || 'ОБРЕЗКА ФОТО'}
</span>
<button
onClick={onClose}
className="p-2 rounded-xl bg-black/40 hover:bg-black/60 text-white/50 hover:text-white transition-all pointer-events-auto"
>
<X size={20} />
</button>
</div>
{/* Cropper Area */}
<div className="flex-1 relative bg-[#0a0a0a]">
<Cropper
image={image}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid={false}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
classes={{
containerClassName: 'bg-[#0a0a0a]',
cropAreaClassName: 'rounded-[3rem] border-2 border-white/50 shadow-[0_0_0_9999px_rgba(0,0,0,0.6)]'
}}
/>
</div>
{/* Controls */}
<div className="p-8 pb-10 bg-surface-container relative z-50">
<div className="flex items-center gap-4 mb-8">
<ZoomOut size={16} className="text-zinc-500" />
<input
type="range"
min={1}
max={3}
step={0.1}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer accent-primary"
/>
<ZoomIn size={16} className="text-zinc-500" />
</div>
<div className="flex items-center gap-4">
<button
onClick={() => { setCrop({ x: 0, y: 0 }); setZoom(1); }}
className="p-4 rounded-2xl bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10 transition-all"
title="Сбросить"
>
<RotateCcw size={20} />
</button>
<button
onClick={handleSave}
className="flex-1 py-4 rounded-2xl bg-primary text-on-primary font-bold shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-3"
>
<Crop size={20} />
{t('saveChanges') || 'Применить'}
</button>
</div>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
<div className="max-w-[700px] mx-auto space-y-12">
{/* Header Section */}
<div className="flex items-end justify-between border-b border-white/[0.04] pb-8">
<div>
<h1 className="text-4xl font-headline font-black text-white mb-2 leading-none uppercase tracking-tighter">
{t('settings') || 'Settings'}
</h1>
<p className="text-zinc-500 text-sm font-medium tracking-tight">Управляйте своим профилем и настройками приложения</p>
</div>
{!editing ? (
<button
onClick={() => setEditing(true)}
className="px-6 py-2.5 rounded-2xl bg-white/5 border border-white/10 text-white text-xs font-black uppercase tracking-widest hover:bg-primary hover:text-on-primary hover:border-primary transition-all flex items-center gap-2 group"
>
<Edit3 size={14} className="group-hover:scale-110 transition-transform" />
{t('edit') || 'Изменить'}
</button>
) : (
<div className="flex items-center gap-3">
<button
onClick={() => setEditing(false)}
className="px-6 py-2.5 rounded-2xl text-on-surface-variant text-xs font-black uppercase tracking-widest hover:text-white transition-colors"
>
{t('cancel') || 'Отмена'}
</button>
<button
onClick={handleSaveProfile}
disabled={saving}
className="px-8 py-2.5 rounded-2xl bg-primary text-on-primary text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.03] active:scale-95 transition-all flex items-center gap-3"
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t('save') || 'Сохранить'}
</button>
</div>
)}
</div>
{/* Profile/Avatar Section */}
<div className="flex flex-col md:flex-row gap-12 items-start py-4">
<div className="relative group">
{/* Avatar Glow */}
<div className={`absolute -inset-4 bg-primary/20 blur-3xl rounded-full opacity-40 group-hover:opacity-60 transition-opacity ${uploadingAvatar ? 'animate-pulse' : ''}`} />
<div className="relative w-48 h-48 rounded-[3rem] bg-zinc-900 border-2 border-white/10 p-1.5 overflow-hidden shadow-2xl transition-transform duration-500 group-hover:scale-[1.02]">
<div className="relative w-full h-full rounded-[2.5rem] overflow-hidden bg-surface-container flex items-center justify-center">
{user?.avatar ? (
<img
src={getMediaUrl(user.avatar)}
className={`w-full h-full object-cover transition-opacity duration-500 ${uploadingAvatar ? 'opacity-40' : 'opacity-100'}`}
alt=""
/>
) : (
<div className="w-full h-full bg-linear-to-br from-primary/30 to-primary-container/10 flex items-center justify-center text-5xl font-black text-primary">
{initials}
</div>
)}
{uploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[2px]">
<Loader2 size={32} className="text-primary animate-spin" />
</div>
)}
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 rounded-full bg-primary text-on-primary shadow-xl scale-90 group-hover:scale-100 transition-all"
>
<Camera size={24} />
</button>
{user?.avatar && (
<button
onClick={removeAvatar}
className="p-2 text-white/40 hover:text-red-400 transition-colors"
>
<Trash2 size={18} />
</button>
)}
</div>
</div>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleAvatarChange}
/>
</div>
<div className="flex-1 space-y-8 w-full">
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant opacity-40">{t('displayName') || 'Имя профиля'}</label>
{editing ? (
<input
type="text"
value={formData.displayName}
onChange={(e) => 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="Как вас называть?"
/>
) : (
<p className="text-3xl font-black text-white tracking-tighter">{user?.displayName}</p>
)}
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-on-surface-variant opacity-40">
<AtSign size={12} />
<label className="text-[10px] font-black uppercase tracking-[0.2em]">{t('username') || 'Username'}</label>
</div>
<p className="text-base font-bold text-zinc-400">@{user?.username || user?.userName}</p>
</div>
</div>
</div>
{/* Bio & Details Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Info size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('about') || 'О себе'}</span>
</div>
{editing ? (
<textarea
value={formData.bio}
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
placeholder="Расскажите немного о себе..."
/>
) : (
<p className="text-base text-white/70 leading-relaxed font-medium min-h-[4rem]">
{user?.bio || 'Информация не указана'}
</p>
)}
</div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Calendar size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('birthday') || 'Дата рождения'}</span>
</div>
{editing ? (
<DatePicker
value={formData.birthday}
onChange={(val) => setFormData(p => ({ ...p, birthday: val }))}
/>
) : (
<p className="text-base text-white/70 leading-relaxed font-black uppercase tracking-widest">
{user?.birthday ? new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Не указана'}
</p>
)}
</div>
</div>
{/* App Settings */}
<div className="space-y-6 pt-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-px h-6 bg-primary/40 rounded-full" />
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-white/40">Настройки приложения</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Language Picker */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 space-y-4">
<div className="flex items-center gap-3 opacity-60">
<Languages size={16} className="text-zinc-500" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">{t('language') || 'Language'}</span>
</div>
<div className="flex gap-2 p-1.5 rounded-2xl bg-black/20 w-fit">
<button
onClick={() => setLang('ru')}
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'ru' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
>
RU
</button>
<button
onClick={() => setLang('en')}
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'en' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
>
EN
</button>
</div>
</div>
{/* Account Section */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
<button
onClick={() => { logout(); }}
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center text-red-500 group-hover:scale-105 transition-transform">
<LogOut size={16} />
</div>
<span className="text-[11px] font-black uppercase tracking-widest text-red-500/70 group-hover:text-red-500 transition-colors">{t('logout') || 'Выйти'}</span>
</div>
<ChevronRight size={16} className="text-red-500/40" />
</button>
</div>
</div>
</div>
</div>
<AnimatePresence>
{cropModal.open && (
<AvatarCropModal
image={cropModal.image}
onCrop={onCropConfirm}
onClose={() => setCropModal({ open: false, image: '', file: null })}
/>
)}
</AnimatePresence>
</div>
);
}