noteJS-react/src/pages/ProfilePage.tsx

638 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;