- Реализованы API для сохранения и получения AI настроек пользователя, включая OpenAI API ключ, базовый URL и модель. - Добавлена возможность окончательного удаления всех архивных заметок с подтверждением пароля. - Внедрена функция улучшения текста через AI, с обработкой запросов к OpenAI API. - Обновлены интерфейсы для работы с AI настройками и добавлены уведомления для улучшения пользовательского опыта.
677 lines
22 KiB
JavaScript
677 lines
22 KiB
JavaScript
// Система стека уведомлений
|
||
let notificationStack = [];
|
||
|
||
// Универсальная функция для показа уведомлений
|
||
function showNotification(message, type = "info") {
|
||
// Создаем уведомление
|
||
const notification = document.createElement("div");
|
||
notification.className = `notification notification-${type}`;
|
||
notification.textContent = message;
|
||
notification.id = `notification-${Date.now()}-${Math.random()
|
||
.toString(36)
|
||
.substr(2, 9)}`;
|
||
|
||
// Добавляем возможность закрытия по клику
|
||
notification.style.cursor = "pointer";
|
||
notification.addEventListener("click", () => {
|
||
removeNotification(notification);
|
||
});
|
||
|
||
// Добавляем на страницу
|
||
document.body.appendChild(notification);
|
||
|
||
// Добавляем в стек
|
||
notificationStack.push(notification);
|
||
|
||
// Обновляем позиции всех уведомлений
|
||
updateNotificationPositions();
|
||
|
||
// Анимация появления
|
||
setTimeout(() => {
|
||
notification.style.transform = "translateX(-50%) translateY(0)";
|
||
}, 100);
|
||
|
||
// Автоматическое удаление через 4 секунды
|
||
setTimeout(() => {
|
||
removeNotification(notification);
|
||
}, 4000);
|
||
}
|
||
|
||
// Функция для удаления уведомления
|
||
function removeNotification(notification) {
|
||
// Анимация исчезновения
|
||
notification.style.transform = "translateX(-50%) translateY(-100%)";
|
||
|
||
setTimeout(() => {
|
||
// Удаляем из DOM
|
||
if (notification.parentNode) {
|
||
notification.parentNode.removeChild(notification);
|
||
}
|
||
|
||
// Удаляем из стека
|
||
const index = notificationStack.indexOf(notification);
|
||
if (index > -1) {
|
||
notificationStack.splice(index, 1);
|
||
}
|
||
|
||
// Обновляем позиции оставшихся уведомлений
|
||
updateNotificationPositions();
|
||
}, 300);
|
||
}
|
||
|
||
// Функция для обновления позиций уведомлений
|
||
function updateNotificationPositions() {
|
||
notificationStack.forEach((notification, index) => {
|
||
const offset = index * 70; // 70px между уведомлениями
|
||
notification.style.top = `${20 + offset}px`;
|
||
});
|
||
}
|
||
|
||
// Универсальная функция для модальных окон подтверждения
|
||
function showConfirmModal(title, message, options = {}) {
|
||
return new Promise((resolve) => {
|
||
// Создаем модальное окно
|
||
const modal = document.createElement("div");
|
||
modal.className = "modal";
|
||
modal.style.display = "block";
|
||
|
||
// Создаем содержимое модального окна
|
||
const modalContent = document.createElement("div");
|
||
modalContent.className = "modal-content";
|
||
|
||
// Создаем заголовок
|
||
const modalHeader = document.createElement("div");
|
||
modalHeader.className = "modal-header";
|
||
modalHeader.innerHTML = `
|
||
<h3>${title}</h3>
|
||
<span class="modal-close">×</span>
|
||
`;
|
||
|
||
// Создаем тело модального окна
|
||
const modalBody = document.createElement("div");
|
||
modalBody.className = "modal-body";
|
||
modalBody.innerHTML = `<p>${message}</p>`;
|
||
|
||
// Создаем футер с кнопками
|
||
const modalFooter = document.createElement("div");
|
||
modalFooter.className = "modal-footer";
|
||
modalFooter.innerHTML = `
|
||
<button id="confirmBtn" class="${
|
||
options.confirmType === "danger" ? "btn-danger" : "btn-primary"
|
||
}" style="margin-right: 10px">
|
||
${options.confirmText || "OK"}
|
||
</button>
|
||
<button id="cancelBtn" class="btn-secondary">
|
||
${options.cancelText || "Отмена"}
|
||
</button>
|
||
`;
|
||
|
||
// Собираем модальное окно
|
||
modalContent.appendChild(modalHeader);
|
||
modalContent.appendChild(modalBody);
|
||
modalContent.appendChild(modalFooter);
|
||
modal.appendChild(modalContent);
|
||
|
||
// Добавляем на страницу
|
||
document.body.appendChild(modal);
|
||
|
||
// Функция закрытия
|
||
function closeModal() {
|
||
modal.style.display = "none";
|
||
if (modal.parentNode) {
|
||
modal.parentNode.removeChild(modal);
|
||
}
|
||
}
|
||
|
||
// Обработчики событий
|
||
const closeBtn = modalHeader.querySelector(".modal-close");
|
||
const cancelBtn = modalFooter.querySelector("#cancelBtn");
|
||
const confirmBtn = modalFooter.querySelector("#confirmBtn");
|
||
|
||
closeBtn.addEventListener("click", () => {
|
||
closeModal();
|
||
resolve(false);
|
||
});
|
||
|
||
cancelBtn.addEventListener("click", () => {
|
||
closeModal();
|
||
resolve(false);
|
||
});
|
||
|
||
confirmBtn.addEventListener("click", () => {
|
||
closeModal();
|
||
resolve(true);
|
||
});
|
||
|
||
// Закрытие при клике вне модального окна
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === modal) {
|
||
closeModal();
|
||
resolve(false);
|
||
}
|
||
});
|
||
|
||
// Закрытие по Escape
|
||
const handleEscape = (e) => {
|
||
if (e.key === "Escape") {
|
||
closeModal();
|
||
resolve(false);
|
||
document.removeEventListener("keydown", handleEscape);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", handleEscape);
|
||
});
|
||
}
|
||
|
||
// Логика переключения темы
|
||
function initThemeToggle() {
|
||
const themeToggleBtn = document.getElementById("theme-toggle-btn");
|
||
|
||
if (!themeToggleBtn) return;
|
||
|
||
// Загружаем сохраненную тему или используем системную
|
||
const savedTheme = localStorage.getItem("theme");
|
||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||
? "dark"
|
||
: "light";
|
||
const currentTheme = savedTheme || systemTheme;
|
||
|
||
// Применяем тему
|
||
applyTheme(currentTheme);
|
||
|
||
// Обработчик клика на переключатель
|
||
themeToggleBtn.addEventListener("click", () => {
|
||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||
applyTheme(newTheme);
|
||
localStorage.setItem("theme", newTheme);
|
||
});
|
||
|
||
// Слушаем изменения системной темы
|
||
window
|
||
.matchMedia("(prefers-color-scheme: dark)")
|
||
.addEventListener("change", (e) => {
|
||
if (!localStorage.getItem("theme")) {
|
||
applyTheme(e.matches ? "dark" : "light");
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
|
||
// Обновляем meta теги для PWA
|
||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||
if (themeColorMeta) {
|
||
themeColorMeta.setAttribute(
|
||
"content",
|
||
theme === "dark" ? "#1a1a1a" : "#007bff"
|
||
);
|
||
}
|
||
|
||
// Обновляем иконку переключателя
|
||
const themeToggleBtn = document.getElementById("theme-toggle-btn");
|
||
if (themeToggleBtn) {
|
||
const icon = themeToggleBtn.querySelector(".iconify");
|
||
if (icon) {
|
||
icon.setAttribute(
|
||
"data-icon",
|
||
theme === "dark" ? "mdi:weather-sunny" : "mdi:weather-night"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Инициализируем переключатель темы при загрузке страницы
|
||
document.addEventListener("DOMContentLoaded", initThemeToggle);
|
||
|
||
// DOM элементы
|
||
const avatarImage = document.getElementById("avatarImage");
|
||
const avatarPlaceholder = document.getElementById("avatarPlaceholder");
|
||
const avatarInput = document.getElementById("avatarInput");
|
||
const deleteAvatarBtn = document.getElementById("deleteAvatarBtn");
|
||
const usernameInput = document.getElementById("username");
|
||
const emailInput = document.getElementById("email");
|
||
const updateProfileBtn = document.getElementById("updateProfileBtn");
|
||
const currentPasswordInput = document.getElementById("currentPassword");
|
||
const newPasswordInput = document.getElementById("newPassword");
|
||
const confirmPasswordInput = document.getElementById("confirmPassword");
|
||
const changePasswordBtn = document.getElementById("changePasswordBtn");
|
||
|
||
// Кэширование аватарки
|
||
const AVATAR_CACHE_KEY = "avatar_cache";
|
||
const AVATAR_TIMESTAMP_KEY = "avatar_timestamp";
|
||
|
||
// Функция для кэширования аватарки
|
||
async function cacheAvatar(avatarUrl) {
|
||
try {
|
||
const response = await fetch(avatarUrl);
|
||
if (!response.ok) return false;
|
||
|
||
const blob = await response.blob();
|
||
const base64 = await blobToBase64(blob);
|
||
|
||
const cacheData = {
|
||
base64: base64,
|
||
timestamp: Date.now(),
|
||
url: avatarUrl,
|
||
};
|
||
|
||
localStorage.setItem(AVATAR_CACHE_KEY, JSON.stringify(cacheData));
|
||
localStorage.setItem(AVATAR_TIMESTAMP_KEY, Date.now().toString());
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error("Ошибка кэширования аватарки:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Функция для получения аватарки из кэша
|
||
function getCachedAvatar() {
|
||
try {
|
||
const cacheData = localStorage.getItem(AVATAR_CACHE_KEY);
|
||
const timestamp = localStorage.getItem(AVATAR_TIMESTAMP_KEY);
|
||
|
||
if (!cacheData || !timestamp) return null;
|
||
|
||
const data = JSON.parse(cacheData);
|
||
const cacheAge = Date.now() - parseInt(timestamp);
|
||
|
||
// Кэш действителен 24 часа (86400000 мс)
|
||
if (cacheAge > 24 * 60 * 60 * 1000) {
|
||
clearAvatarCache();
|
||
return null;
|
||
}
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error("Ошибка получения аватарки из кэша:", error);
|
||
clearAvatarCache();
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Функция для очистки кэша аватарки
|
||
function clearAvatarCache() {
|
||
localStorage.removeItem(AVATAR_CACHE_KEY);
|
||
localStorage.removeItem(AVATAR_TIMESTAMP_KEY);
|
||
}
|
||
|
||
// Преобразование Blob в base64
|
||
function blobToBase64(blob) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
// Lazy loading для изображений
|
||
function initLazyLoading() {
|
||
// Проверяем поддержку Intersection Observer API
|
||
if ("IntersectionObserver" in window) {
|
||
const imageObserver = new IntersectionObserver(
|
||
(entries, observer) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
const img = entry.target;
|
||
// Если у изображения есть data-src, загружаем его
|
||
if (img.dataset.src) {
|
||
img.src = img.dataset.src;
|
||
img.removeAttribute("data-src");
|
||
}
|
||
img.classList.remove("lazy");
|
||
observer.unobserve(img);
|
||
}
|
||
});
|
||
},
|
||
{
|
||
rootMargin: "50px 0px",
|
||
threshold: 0.01,
|
||
}
|
||
);
|
||
|
||
// Наблюдаем за всеми изображениями с классом lazy
|
||
document.querySelectorAll("img.lazy").forEach((img) => {
|
||
imageObserver.observe(img);
|
||
});
|
||
} else {
|
||
// Fallback для старых браузеров
|
||
document.querySelectorAll("img.lazy").forEach((img) => {
|
||
if (img.dataset.src) {
|
||
img.src = img.dataset.src;
|
||
img.removeAttribute("data-src");
|
||
}
|
||
img.classList.remove("lazy");
|
||
});
|
||
}
|
||
}
|
||
|
||
// Загрузка данных профиля
|
||
async function loadProfile() {
|
||
try {
|
||
const response = await fetch("/api/user");
|
||
if (!response.ok) {
|
||
throw new Error("Ошибка загрузки профиля");
|
||
}
|
||
|
||
const user = await response.json();
|
||
|
||
// Заполняем поля
|
||
usernameInput.value = user.username || "";
|
||
emailInput.value = user.email || "";
|
||
|
||
// Применяем цветовой акцент пользователя (для отображения)
|
||
const accentColor = user.accent_color || "#007bff";
|
||
document.documentElement.style.setProperty("--accent-color", accentColor);
|
||
|
||
// Обрабатываем аватарку
|
||
if (user.avatar) {
|
||
// Проверяем, есть ли аватарка в кэше
|
||
const cachedAvatar = getCachedAvatar();
|
||
|
||
if (cachedAvatar && cachedAvatar.url === user.avatar) {
|
||
// Используем кэшированную аватарку
|
||
avatarImage.src = cachedAvatar.base64;
|
||
console.log("Аватарка загружена из кэша");
|
||
} else {
|
||
// Загружаем аватарку с сервера и кэшируем
|
||
avatarImage.src = user.avatar;
|
||
// Кэшируем в фоне
|
||
cacheAvatar(user.avatar).then((success) => {
|
||
if (success) {
|
||
console.log("Аватарка закэширована");
|
||
}
|
||
});
|
||
}
|
||
|
||
avatarImage.style.display = "block";
|
||
avatarPlaceholder.style.display = "none";
|
||
deleteAvatarBtn.style.display = "inline-block";
|
||
} else {
|
||
avatarImage.style.display = "none";
|
||
avatarPlaceholder.style.display = "inline-flex";
|
||
deleteAvatarBtn.style.display = "none";
|
||
// Очищаем кэш, если аватарки нет
|
||
clearAvatarCache();
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки профиля:", error);
|
||
showNotification("Ошибка загрузки данных профиля", "error");
|
||
}
|
||
}
|
||
|
||
// Обработчик загрузки аватарки
|
||
avatarInput.addEventListener("change", async function (event) {
|
||
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 formData = new FormData();
|
||
formData.append("avatar", file);
|
||
|
||
const response = await fetch("/api/user/avatar", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка загрузки аватарки");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Обновляем отображение аватарки
|
||
avatarImage.src = result.avatar + "?t=" + Date.now(); // Добавляем timestamp для обновления кэша браузера
|
||
avatarImage.style.display = "block";
|
||
avatarPlaceholder.style.display = "none";
|
||
deleteAvatarBtn.style.display = "inline-block";
|
||
|
||
// Обновляем кэш с новой аватаркой
|
||
cacheAvatar(result.avatar).then((success) => {
|
||
if (success) {
|
||
console.log("Новая аватарка закэширована");
|
||
}
|
||
});
|
||
|
||
showNotification("Аватарка успешно загружена", "success");
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки аватарки:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
|
||
// Сбрасываем input для возможности повторной загрузки того же файла
|
||
event.target.value = "";
|
||
});
|
||
|
||
// Обработчик удаления аватарки
|
||
deleteAvatarBtn.addEventListener("click", async function () {
|
||
const confirmed = await showConfirmModal(
|
||
"Подтверждение удаления",
|
||
"Вы уверены, что хотите удалить аватарку?",
|
||
{ confirmType: "danger", confirmText: "Удалить" }
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/user/avatar", {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка удаления аватарки");
|
||
}
|
||
|
||
// Обновляем отображение
|
||
avatarImage.style.display = "none";
|
||
avatarPlaceholder.style.display = "inline-flex";
|
||
deleteAvatarBtn.style.display = "none";
|
||
|
||
// Очищаем кэш аватарки
|
||
clearAvatarCache();
|
||
|
||
showNotification("Аватарка успешно удалена", "success");
|
||
} catch (error) {
|
||
console.error("Ошибка удаления аватарки:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
});
|
||
|
||
// Обработчик обновления профиля
|
||
updateProfileBtn.addEventListener("click", async function () {
|
||
const username = usernameInput.value.trim();
|
||
const email = emailInput.value.trim();
|
||
|
||
// Валидация
|
||
if (!username) {
|
||
showNotification("Логин не может быть пустым", "error");
|
||
return;
|
||
}
|
||
|
||
if (username.length < 3) {
|
||
showNotification("Логин должен быть не менее 3 символов", "error");
|
||
return;
|
||
}
|
||
|
||
if (email && !isValidEmail(email)) {
|
||
showNotification("Некорректный email адрес", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/user/profile", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
username,
|
||
email: email || null,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка обновления профиля");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
showNotification(result.message || "Профиль успешно обновлен", "success");
|
||
} catch (error) {
|
||
console.error("Ошибка обновления профиля:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
});
|
||
|
||
// Обработчик изменения пароля
|
||
changePasswordBtn.addEventListener("click", async function () {
|
||
const currentPassword = currentPasswordInput.value;
|
||
const newPassword = newPasswordInput.value;
|
||
const confirmPassword = confirmPasswordInput.value;
|
||
|
||
// Валидация
|
||
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 {
|
||
const response = await fetch("/api/user/profile", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
currentPassword,
|
||
newPassword,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка изменения пароля");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Очищаем поля паролей
|
||
currentPasswordInput.value = "";
|
||
newPasswordInput.value = "";
|
||
confirmPasswordInput.value = "";
|
||
|
||
showNotification(result.message || "Пароль успешно изменен", "success");
|
||
} catch (error) {
|
||
console.error("Ошибка изменения пароля:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
});
|
||
|
||
// Функция валидации email
|
||
function isValidEmail(email) {
|
||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return re.test(email);
|
||
}
|
||
|
||
// Функция для проверки аутентификации
|
||
async function checkAuthentication() {
|
||
const isAuthenticated = localStorage.getItem("isAuthenticated");
|
||
|
||
if (isAuthenticated !== "true") {
|
||
// Если пользователь не аутентифицирован, перенаправляем на страницу входа
|
||
window.location.href = "/";
|
||
return;
|
||
}
|
||
|
||
// Проверяем, что сессия на сервере еще действительна
|
||
try {
|
||
const response = await fetch("/api/auth/status");
|
||
if (!response.ok) {
|
||
// Если сессия недействительна, очищаем localStorage и перенаправляем
|
||
localStorage.removeItem("isAuthenticated");
|
||
localStorage.removeItem("username");
|
||
window.location.href = "/";
|
||
return;
|
||
}
|
||
|
||
const authData = await response.json();
|
||
if (!authData.authenticated) {
|
||
// Если сервер говорит, что пользователь не аутентифицирован
|
||
localStorage.removeItem("isAuthenticated");
|
||
localStorage.removeItem("username");
|
||
window.location.href = "/";
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка проверки аутентификации:", error);
|
||
// В случае ошибки сети, оставляем пользователя на странице
|
||
// но показываем предупреждение
|
||
console.warn("Не удалось проверить статус аутентификации");
|
||
}
|
||
}
|
||
|
||
// Функция для настройки обработчика выхода
|
||
function setupLogoutHandler() {
|
||
const logoutForms = document.querySelectorAll('form[action="/logout"]');
|
||
logoutForms.forEach((form) => {
|
||
form.addEventListener("submit", function (e) {
|
||
// Очищаем localStorage перед выходом
|
||
localStorage.removeItem("isAuthenticated");
|
||
localStorage.removeItem("username");
|
||
});
|
||
});
|
||
}
|
||
|
||
// Загружаем профиль при загрузке страницы
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
// Проверяем аутентификацию при загрузке страницы
|
||
checkAuthentication();
|
||
loadProfile();
|
||
|
||
// Инициализируем lazy loading для изображений
|
||
initLazyLoading();
|
||
|
||
// Добавляем обработчик для кнопки выхода
|
||
setupLogoutHandler();
|
||
});
|