// Система стека уведомлений 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(() => { if (window.innerWidth <= 768) { // На мобильных устройствах используем только translateY notification.style.transform = "translateY(0)"; } else { // На десктопе используем translateX(-50%) translateY(0) notification.style.transform = "translateX(-50%) translateY(0)"; } }, 100); // Автоматическое удаление через 4 секунды setTimeout(() => { removeNotification(notification); }, 4000); } // Функция для удаления уведомления function removeNotification(notification) { // Анимация исчезновения if (window.innerWidth <= 768) { // На мобильных устройствах используем только translateY notification.style.transform = "translateY(-100%)"; } else { // На десктопе используем translateX(-50%) translateY(-100%) 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`; // Обновляем transform в зависимости от размера экрана if (window.innerWidth <= 768) { // На мобильных устройствах используем только translateY notification.style.transform = "translateY(0)"; } else { // На десктопе используем translateX(-50%) translateY(0) notification.style.transform = "translateX(-50%) translateY(0)"; } }); } // Обработчик изменения размера окна window.addEventListener("resize", () => { updateNotificationPositions(); }); // Универсальная функция для модальных окон подтверждения 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 = `

${title}

× `; // Создаем тело модального окна const modalBody = document.createElement("div"); modalBody.className = "modal-body"; modalBody.innerHTML = `

${message}

`; // Создаем футер с кнопками const modalFooter = document.createElement("div"); modalFooter.className = "modal-footer"; modalFooter.innerHTML = ` `; // Собираем модальное окно 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"); }); }); } // Функция для инициализации обработчика удаления аккаунта function initDeleteAccountHandler() { const deleteAccountBtn = document.getElementById("deleteAccountBtn"); const modal = document.getElementById("deleteAccountModal"); const closeBtn = document.getElementById("deleteAccountModalClose"); const cancelBtn = document.getElementById("cancelDeleteAccount"); const confirmBtn = document.getElementById("confirmDeleteAccount"); const passwordInput = document.getElementById("deleteAccountPassword"); // Открытие модального окна deleteAccountBtn.addEventListener("click", () => { // Очищаем поле пароля и открываем модальное окно passwordInput.value = ""; modal.style.display = "block"; passwordInput.focus(); }); // Закрытие модального окна function closeModal() { modal.style.display = "none"; passwordInput.value = ""; } closeBtn.addEventListener("click", closeModal); cancelBtn.addEventListener("click", closeModal); // Закрытие при клике вне модального окна window.addEventListener("click", (e) => { if (e.target === modal) { closeModal(); } }); // Подтверждение удаления confirmBtn.addEventListener("click", async () => { const password = passwordInput.value.trim(); if (!password) { showNotification("Введите пароль", "warning"); passwordInput.focus(); return; } // Блокируем кнопку во время выполнения confirmBtn.disabled = true; confirmBtn.innerHTML = ' Удаление...'; try { const response = await fetch("/api/user/delete-account", { method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ password }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Ошибка удаления аккаунта"); } // Успешное удаление showNotification("Аккаунт успешно удален", "success"); // Очищаем localStorage localStorage.removeItem("isAuthenticated"); localStorage.removeItem("username"); // Перенаправляем на главную страницу setTimeout(() => { window.location.href = "/"; }, 2000); } catch (error) { console.error("Ошибка:", error); showNotification(error.message || "Ошибка удаления аккаунта", "error"); } finally { // Разблокируем кнопку confirmBtn.disabled = false; confirmBtn.innerHTML = ' Удалить аккаунт'; } }); // Обработка Enter в поле пароля passwordInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { confirmBtn.click(); } }); } // Загружаем профиль при загрузке страницы document.addEventListener("DOMContentLoaded", function () { // Проверяем аутентификацию при загрузке страницы checkAuthentication(); loadProfile(); // Инициализируем lazy loading для изображений initLazyLoading(); // Добавляем обработчик для кнопки выхода setupLogoutHandler(); // Инициализируем обработчик удаления аккаунта initDeleteAccountHandler(); });