638 lines
22 KiB
TypeScript
638 lines
22 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { Icon } from "@iconify/react";
|
||
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||
import { userApi } from "../api/userApi";
|
||
import { authApi } from "../api/authApi";
|
||
import { clearAuth } from "../store/slices/authSlice";
|
||
import { setUser, setAiSettings } from "../store/slices/profileSlice";
|
||
import { setAccentColor as setAccentColorAction } from "../store/slices/uiSlice";
|
||
import { setAccentColor } from "../utils/colorUtils";
|
||
import { useNotification } from "../hooks/useNotification";
|
||
import { Modal } from "../components/common/Modal";
|
||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||
import { dbManager } from "../utils/indexedDB";
|
||
import { TwoFactorSetup } from "../components/twoFactor/TwoFactorSetup";
|
||
|
||
const ProfilePage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const dispatch = useAppDispatch();
|
||
const { showNotification } = useNotification();
|
||
// @ts-expect-error - переменная может использоваться в будущем
|
||
const _user = useAppSelector((state) => state.profile.user);
|
||
|
||
const [username, setUsername] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||
const [hasAvatar, setHasAvatar] = useState(false);
|
||
|
||
const [currentPassword, setCurrentPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
|
||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||
const [deletePassword, setDeletePassword] = useState("");
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
|
||
// 2FA settings
|
||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||
const [isLoading2FA, setIsLoading2FA] = useState(false);
|
||
|
||
// Public profile settings
|
||
const [isPublicProfile, setIsPublicProfile] = useState(false);
|
||
const [publicProfileLink, setPublicProfileLink] = useState("");
|
||
|
||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
loadProfile();
|
||
load2FAStatus();
|
||
}, []);
|
||
|
||
const load2FAStatus = async () => {
|
||
setIsLoading2FA(true);
|
||
try {
|
||
const status = await userApi.get2FAStatus();
|
||
setTwoFactorEnabled(status.twoFactorEnabled);
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки статуса 2FA:", error);
|
||
} finally {
|
||
setIsLoading2FA(false);
|
||
}
|
||
};
|
||
|
||
const handle2FAStatusChange = async () => {
|
||
await load2FAStatus();
|
||
await loadProfile();
|
||
};
|
||
|
||
const loadProfile = async () => {
|
||
try {
|
||
const userData = await userApi.getProfile();
|
||
dispatch(setUser(userData));
|
||
setUsername(userData.username || "");
|
||
setEmail(userData.email || "");
|
||
|
||
// Устанавливаем цвет акцента из профиля пользователя
|
||
const accent = userData.accent_color || "#007bff";
|
||
dispatch(setAccentColorAction(accent));
|
||
setAccentColor(accent);
|
||
|
||
if (userData.avatar) {
|
||
setAvatarUrl(userData.avatar);
|
||
setHasAvatar(true);
|
||
} else {
|
||
setAvatarUrl(null);
|
||
setHasAvatar(false);
|
||
}
|
||
|
||
// Устанавливаем состояние публичного профиля
|
||
setIsPublicProfile(userData.is_public_profile === 1);
|
||
if (userData.is_public_profile === 1) {
|
||
const link = `${window.location.origin}/public/${userData.username}`;
|
||
setPublicProfileLink(link);
|
||
} else {
|
||
setPublicProfileLink("");
|
||
}
|
||
|
||
// Загружаем AI настройки
|
||
try {
|
||
const aiSettings = await userApi.getAiSettings();
|
||
dispatch(setAiSettings(aiSettings));
|
||
} catch (aiError) {
|
||
console.error("Ошибка загрузки AI настроек:", aiError);
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки профиля:", error);
|
||
showNotification("Ошибка загрузки данных профиля", "error");
|
||
}
|
||
};
|
||
|
||
const handleAvatarUpload = async (
|
||
event: React.ChangeEvent<HTMLInputElement>
|
||
) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// Проверка размера файла (5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
showNotification(
|
||
"Файл слишком большой. Максимальный размер: 5 МБ",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Проверка типа файла
|
||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif"];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
showNotification(
|
||
"Недопустимый формат файла. Используйте JPG, PNG или GIF",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await userApi.uploadAvatar(file);
|
||
setAvatarUrl(result.avatar + "?t=" + Date.now());
|
||
setHasAvatar(true);
|
||
await loadProfile();
|
||
showNotification("Аватарка успешно загружена", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка загрузки аватарки:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка загрузки аватарки",
|
||
"error"
|
||
);
|
||
}
|
||
|
||
// Сбрасываем input
|
||
if (avatarInputRef.current) {
|
||
avatarInputRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const handleDeleteAvatar = async () => {
|
||
try {
|
||
await userApi.deleteAvatar();
|
||
setAvatarUrl(null);
|
||
setHasAvatar(false);
|
||
await loadProfile();
|
||
showNotification("Аватарка успешно удалена", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка удаления аватарки:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка удаления аватарки",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleUpdateProfile = async () => {
|
||
if (!username.trim()) {
|
||
showNotification("Логин не может быть пустым", "error");
|
||
return;
|
||
}
|
||
|
||
if (username.length < 3) {
|
||
showNotification("Логин должен быть не менее 3 символов", "error");
|
||
return;
|
||
}
|
||
|
||
if (email && !isValidEmail(email)) {
|
||
showNotification("Некорректный email адрес", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await userApi.updateProfile({
|
||
username: username.trim(),
|
||
email: email.trim() || undefined,
|
||
});
|
||
await loadProfile();
|
||
showNotification("Профиль успешно обновлен", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка обновления профиля:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка обновления профиля",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleChangePassword = async () => {
|
||
if (!currentPassword) {
|
||
showNotification("Введите текущий пароль", "error");
|
||
return;
|
||
}
|
||
|
||
if (!newPassword) {
|
||
showNotification("Введите новый пароль", "error");
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 6) {
|
||
showNotification("Новый пароль должен быть не менее 6 символов", "error");
|
||
return;
|
||
}
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
showNotification("Новый пароль и подтверждение не совпадают", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await userApi.updateProfile({
|
||
currentPassword,
|
||
newPassword,
|
||
});
|
||
setCurrentPassword("");
|
||
setNewPassword("");
|
||
setConfirmPassword("");
|
||
showNotification("Пароль успешно изменен", "success");
|
||
} catch (error: any) {
|
||
console.error("Ошибка изменения пароля:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка изменения пароля",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAccount = async () => {
|
||
if (!deletePassword.trim()) {
|
||
showNotification("Введите пароль", "warning");
|
||
return;
|
||
}
|
||
|
||
setIsDeleting(true);
|
||
try {
|
||
await userApi.deleteAccount(deletePassword);
|
||
// Очищаем IndexedDB при удалении аккаунта
|
||
await dbManager.clearAll();
|
||
showNotification("Аккаунт успешно удален", "success");
|
||
dispatch(clearAuth());
|
||
setTimeout(() => {
|
||
navigate("/");
|
||
}, 2000);
|
||
} catch (error: any) {
|
||
console.error("Ошибка удаления аккаунта:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка удаления аккаунта",
|
||
"error"
|
||
);
|
||
setIsDeleting(false);
|
||
}
|
||
};
|
||
|
||
const isValidEmail = (email: string) => {
|
||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return re.test(email);
|
||
};
|
||
|
||
const handlePublicProfileToggle = async () => {
|
||
const newValue = !isPublicProfile;
|
||
try {
|
||
await userApi.updateProfile({
|
||
is_public_profile: newValue,
|
||
});
|
||
setIsPublicProfile(newValue);
|
||
if (newValue) {
|
||
const link = `${window.location.origin}/public/${username}`;
|
||
setPublicProfileLink(link);
|
||
showNotification("Публичный профиль включен", "success");
|
||
} else {
|
||
setPublicProfileLink("");
|
||
showNotification("Публичный профиль отключен", "success");
|
||
}
|
||
await loadProfile();
|
||
} catch (error: any) {
|
||
console.error("Ошибка обновления публичного профиля:", error);
|
||
showNotification(
|
||
error.response?.data?.error || "Ошибка обновления публичного профиля",
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
const copyPublicProfileLink = () => {
|
||
if (publicProfileLink) {
|
||
navigator.clipboard.writeText(publicProfileLink);
|
||
showNotification("Ссылка скопирована в буфер обмена", "success");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="container">
|
||
<header className="notes-header">
|
||
<span>
|
||
<Icon icon="mdi:account" /> Личный кабинет
|
||
</span>
|
||
<div className="user-info">
|
||
<ThemeToggle />
|
||
<button
|
||
className="notes-btn"
|
||
onClick={() => navigate("/notes")}
|
||
title="К заметкам"
|
||
>
|
||
<Icon icon="mdi:note-text" />
|
||
</button>
|
||
<button
|
||
className="settings-btn"
|
||
onClick={() => navigate("/settings")}
|
||
title="Настройки"
|
||
>
|
||
<Icon icon="mdi:cog" />
|
||
</button>
|
||
<button
|
||
className="logout-btn"
|
||
title="Выйти"
|
||
onClick={async () => {
|
||
try {
|
||
await authApi.logout();
|
||
} catch (error) {
|
||
console.error("Ошибка выхода:", error);
|
||
} finally {
|
||
// Очищаем IndexedDB в фоне (не блокируем выход)
|
||
dbManager.clearAll().catch((err) => {
|
||
console.error("Ошибка очистки IndexedDB при выходе:", err);
|
||
});
|
||
dispatch(clearAuth());
|
||
navigate("/");
|
||
}
|
||
}}
|
||
>
|
||
<Icon icon="mdi:logout" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="profile-container">
|
||
{/* Секция аватарки */}
|
||
<div className="avatar-section">
|
||
<div className="avatar-wrapper">
|
||
{hasAvatar && avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt="Аватар"
|
||
className="avatar-preview"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="avatar-placeholder">
|
||
<Icon icon="mdi:account" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="avatar-buttons">
|
||
<label htmlFor="avatarInput" className="btn-upload">
|
||
<Icon icon="mdi:upload" /> Загрузить аватар
|
||
</label>
|
||
<input
|
||
ref={avatarInputRef}
|
||
type="file"
|
||
id="avatarInput"
|
||
accept="image/*"
|
||
style={{ display: "none" }}
|
||
onChange={handleAvatarUpload}
|
||
/>
|
||
{hasAvatar && (
|
||
<button className="btn-delete" onClick={handleDeleteAvatar}>
|
||
<Icon icon="mdi:delete" /> Удалить
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="avatar-hint">
|
||
Максимальный размер: 5 МБ. Форматы: JPG, PNG, GIF
|
||
</p>
|
||
</div>
|
||
|
||
{/* Секция данных профиля */}
|
||
<div className="profile-form">
|
||
<h3>Данные профиля</h3>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="username">Логин</label>
|
||
<input
|
||
type="text"
|
||
id="username"
|
||
placeholder="Логин"
|
||
minLength={3}
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="email">Email (необязательно)</label>
|
||
<input
|
||
type="email"
|
||
id="email"
|
||
placeholder="example@example.com"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<button className="btnSave" onClick={handleUpdateProfile}>
|
||
Сохранить изменения
|
||
</button>
|
||
|
||
<hr className="separator" />
|
||
|
||
<h3>Изменить пароль</h3>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="currentPassword">Текущий пароль</label>
|
||
<input
|
||
type="password"
|
||
id="currentPassword"
|
||
placeholder="Текущий пароль"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="newPassword">Новый пароль</label>
|
||
<input
|
||
type="password"
|
||
id="newPassword"
|
||
placeholder="Новый пароль (минимум 6 символов)"
|
||
minLength={6}
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="confirmPassword">Подтвердите новый пароль</label>
|
||
<input
|
||
type="password"
|
||
id="confirmPassword"
|
||
placeholder="Подтвердите новый пароль"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<button className="btnSave" onClick={handleChangePassword}>
|
||
Изменить пароль
|
||
</button>
|
||
|
||
<hr className="separator" />
|
||
|
||
<h3>Публичный профиль</h3>
|
||
<div className="form-group">
|
||
<label
|
||
className="ai-toggle-label"
|
||
style={{ marginBottom: "10px", cursor: "pointer" }}
|
||
onClick={handlePublicProfileToggle}
|
||
>
|
||
<div className="toggle-label-content">
|
||
<span className="toggle-text-main">Включить публичный профиль</span>
|
||
<span className="toggle-text-desc">
|
||
При включенном публичном профиле любой пользователь сможет
|
||
просматривать ваши заметки по ссылке с вашим логином.
|
||
</span>
|
||
</div>
|
||
<div className="toggle-switch-wrapper">
|
||
<input
|
||
type="checkbox"
|
||
id="publicProfileToggle"
|
||
className="toggle-checkbox"
|
||
checked={isPublicProfile}
|
||
onChange={handlePublicProfileToggle}
|
||
disabled
|
||
style={{ pointerEvents: "none" }}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</div>
|
||
</label>
|
||
{isPublicProfile && publicProfileLink && (
|
||
<div
|
||
style={{
|
||
marginTop: "15px",
|
||
padding: "15px",
|
||
backgroundColor: "var(--bg-tertiary, #f5f5f5)",
|
||
borderRadius: "5px",
|
||
border: "1px solid var(--border-secondary, #ddd)",
|
||
}}
|
||
>
|
||
<p
|
||
style={{
|
||
marginBottom: "10px",
|
||
fontWeight: "bold",
|
||
fontSize: "14px",
|
||
color: "var(--text-primary, #333)",
|
||
}}
|
||
>
|
||
Ссылка на ваш публичный профиль:
|
||
</p>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "10px",
|
||
}}
|
||
>
|
||
<input
|
||
type="text"
|
||
readOnly
|
||
value={publicProfileLink}
|
||
style={{
|
||
padding: "8px",
|
||
border: "1px solid var(--border-secondary, #ddd)",
|
||
borderRadius: "4px",
|
||
fontSize: "14px",
|
||
backgroundColor: "var(--bg-secondary, #fff)",
|
||
color: "var(--text-primary, #333)",
|
||
width: "100%",
|
||
boxSizing: "border-box",
|
||
}}
|
||
/>
|
||
<button
|
||
className="btnSave"
|
||
onClick={copyPublicProfileLink}
|
||
style={{
|
||
padding: "8px 15px",
|
||
whiteSpace: "nowrap",
|
||
alignSelf: "center",
|
||
}}
|
||
>
|
||
<Icon icon="mdi:content-copy" /> Копировать
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<hr className="separator" />
|
||
|
||
<h3>Безопасность</h3>
|
||
{isLoading2FA ? (
|
||
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
|
||
) : (
|
||
<TwoFactorSetup
|
||
twoFactorEnabled={twoFactorEnabled}
|
||
onStatusChange={handle2FAStatusChange}
|
||
/>
|
||
)}
|
||
|
||
<hr className="separator" />
|
||
|
||
<button
|
||
className="btn-danger"
|
||
onClick={() => setIsDeleteModalOpen(true)}
|
||
>
|
||
<Icon icon="mdi:account-remove" /> Удалить аккаунт
|
||
</button>
|
||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||
Удаление аккаунта - это необратимое действие. Все ваши заметки,
|
||
изображения и данные будут удалены навсегда.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Модальное окно подтверждения удаления аккаунта */}
|
||
<Modal
|
||
isOpen={isDeleteModalOpen}
|
||
onClose={() => {
|
||
setIsDeleteModalOpen(false);
|
||
setDeletePassword("");
|
||
}}
|
||
onConfirm={handleDeleteAccount}
|
||
title="Удаление аккаунта"
|
||
message={
|
||
<>
|
||
<p
|
||
style={{
|
||
color: "#dc3545",
|
||
fontWeight: "bold",
|
||
marginBottom: "15px",
|
||
}}
|
||
>
|
||
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
|
||
</p>
|
||
<p style={{ marginBottom: "20px" }}>
|
||
Вы действительно хотите удалить свой аккаунт? Все ваши заметки,
|
||
изображения, настройки и данные будут удалены навсегда.
|
||
</p>
|
||
<div style={{ marginBottom: "15px" }}>
|
||
<label
|
||
htmlFor="deleteAccountPassword"
|
||
style={{
|
||
display: "block",
|
||
marginBottom: "5px",
|
||
fontWeight: "bold",
|
||
}}
|
||
>
|
||
Введите пароль для подтверждения:
|
||
</label>
|
||
<input
|
||
type="password"
|
||
id="deleteAccountPassword"
|
||
placeholder="Пароль от аккаунта"
|
||
className="modal-password-input"
|
||
value={deletePassword}
|
||
onChange={(e) => setDeletePassword(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === "Enter" && !isDeleting) {
|
||
handleDeleteAccount();
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
}
|
||
confirmText={isDeleting ? "Удаление..." : "Удалить аккаунт"}
|
||
cancelText="Отмена"
|
||
confirmType="danger"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ProfilePage;
|