// Кастомное расширение для скрытого текста (спойлеров) const spoilerExtension = { name: "spoiler", level: "inline", start(src) { return src.match(/\|\|/) ? src.indexOf("||") : -1; }, tokenizer(src, tokens) { const rule = /^\|\|(.*?)\|\|/; const match = rule.exec(src); if (match) { return { type: "spoiler", raw: match[0], text: match[1].trim(), }; } }, renderer(token) { return `${token.text}`; }, }; // Настройка marked.js для поддержки внешних ссылок function setupMarkedRenderer() { // Функция для определения внешних ссылок function isExternalLink(href) { try { const url = new URL(href); return url.origin !== window.location.origin; } catch (e) { // Если URL невалидный, считаем его внутренним return false; } } // Создаем renderer для marked const renderer = new marked.Renderer(); // Переопределяем рендеринг ссылок для открытия внешних ссылок в браузере const originalLink = renderer.link.bind(renderer); renderer.link = function (href, title, text) { const isExternal = isExternalLink(href); if (isExternal) { // Внешние ссылки открываем в браузере return `${text}`; } else { // Внутренние ссылки обрабатываем как обычно return originalLink(href, title, text); } }; // Регистрируем расширение спойлеров marked.use({ extensions: [spoilerExtension] }); // Настраиваем marked marked.setOptions({ gfm: true, breaks: true, renderer: renderer, html: true, }); } // Функция для добавления обработчиков спойлеров function addSpoilerEventListeners() { document.querySelectorAll(".spoiler").forEach((spoiler) => { // Проверяем, не добавлен ли уже обработчик if (spoiler._clickHandler) { return; // Пропускаем, если обработчик уже добавлен } // Создаем новый обработчик spoiler._clickHandler = function (event) { // Если уже раскрыт — не мешаем выделению текста if (this.classList.contains("revealed")) { return; } event.stopPropagation(); this.classList.add("revealed"); console.log("Спойлер кликнут:", this.textContent); }; spoiler.addEventListener("click", spoiler._clickHandler); }); } // Функция для добавления обработчиков внешних ссылок function addExternalLinkListeners() { // Обработчики для внешних ссылок document.querySelectorAll(".external-link").forEach((linkElement) => { // Проверяем, не добавлен ли уже обработчик if (linkElement._externalClickHandler) { return; // Пропускаем, если обработчик уже добавлен } // Создаем новый обработчик linkElement._externalClickHandler = function (event) { // Для PWA приложений открываем ссылку в браузере if ( window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true ) { event.preventDefault(); window.open(this.href, "_blank", "noopener,noreferrer"); } // В обычном браузере оставляем стандартное поведение (target="_blank") }; linkElement.addEventListener("click", linkElement._externalClickHandler); }); } // Логика переключения темы 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", function () { initThemeToggle(); setupMarkedRenderer(); }); // Переменные для пагинации логов let logsOffset = 0; const logsLimit = 50; let hasMoreLogs = true; // DOM элементы для внешнего вида const settingsAccentColorInput = document.getElementById( "settings-accentColor" ); const updateAppearanceBtn = document.getElementById("updateAppearanceBtn"); // Проверка аутентификации async function checkAuthentication() { try { const response = await fetch("/api/auth/status"); if (!response.ok) { localStorage.removeItem("isAuthenticated"); localStorage.removeItem("username"); window.location.href = "/"; return false; } const authData = await response.json(); if (!authData.authenticated) { localStorage.removeItem("isAuthenticated"); localStorage.removeItem("username"); window.location.href = "/"; return false; } return true; } catch (error) { console.error("Ошибка проверки аутентификации:", error); return false; } } // Загрузка информации о пользователе для применения accent color и заполнения формы async function loadUserInfo() { try { const response = await fetch("/api/user"); if (response.ok) { const user = await response.json(); const accentColor = user.accent_color || "#007bff"; // Применяем цветовой акцент if ( getComputedStyle(document.documentElement) .getPropertyValue("--accent-color") .trim() !== accentColor ) { document.documentElement.style.setProperty( "--accent-color", accentColor ); } // Заполняем поле цветового акцента в настройках if (settingsAccentColorInput) { settingsAccentColorInput.value = accentColor; updateColorPickerSelection(accentColor); } } } catch (error) { console.error("Ошибка загрузки информации о пользователе:", error); } } // Функция для обновления выбора цвета в цветовой палитре function updateColorPickerSelection(selectedColor) { const colorOptions = document.querySelectorAll( "#appearance-tab .color-option" ); colorOptions.forEach((option) => { option.classList.remove("selected"); if (option.dataset.color === selectedColor) { option.classList.add("selected"); } }); } // Система стека уведомлений 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 = `
${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 initTabs() { const tabs = document.querySelectorAll(".settings-tab"); const contents = document.querySelectorAll(".tab-content"); tabs.forEach((tab) => { tab.addEventListener("click", () => { const tabName = tab.dataset.tab; // Убираем активный класс со всех табов и контентов tabs.forEach((t) => t.classList.remove("active")); contents.forEach((c) => c.classList.remove("active")); // Добавляем активный класс к выбранному табу и контенту tab.classList.add("active"); document.getElementById(`${tabName}-tab`).classList.add("active"); // Загружаем данные для таба if (tabName === "archive") { loadArchivedNotes(); } else if (tabName === "logs") { loadLogs(true); } else if (tabName === "appearance") { // Данные внешнего вида уже загружены в loadUserInfo() } else if (tabName === "ai") { loadAiSettings(); } }); }); } // Загрузка архивных заметок async function loadArchivedNotes() { const container = document.getElementById("archived-notes-container"); container.innerHTML = 'Загрузка...
'; try { const response = await fetch("/api/notes/archived"); if (!response.ok) { throw new Error("Ошибка загрузки архивных заметок"); } const notes = await response.json(); if (notes.length === 0) { container.innerHTML = 'Архив пуст
'; return; } container.innerHTML = ""; notes.forEach((note) => { const noteDiv = document.createElement("div"); noteDiv.className = "archived-note-item"; noteDiv.dataset.noteId = note.id; // Форматируем дату const created = new Date(note.created_at.replace(" ", "T") + "Z"); const dateStr = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(created); // Преобразуем markdown в HTML для предпросмотра const htmlContent = marked.parse(note.content); const preview = htmlContent.substring(0, 200) + (htmlContent.length > 200 ? "..." : ""); // Изображения let imagesHtml = ""; if (note.images && note.images.length > 0) { imagesHtml = `Ошибка загрузки архивных заметок
'; } } // Добавление обработчиков для архивных заметок function addArchivedNotesEventListeners() { // Добавляем обработчики для спойлеров addSpoilerEventListeners(); // Добавляем обработчики для внешних ссылок addExternalLinkListeners(); // Восстановление document.querySelectorAll(".btn-restore").forEach((btn) => { btn.addEventListener("click", async (e) => { const noteId = e.target.closest("button").dataset.id; const confirmed = await showConfirmModal( "Подтверждение восстановления", "Восстановить эту заметку из архива?", { confirmText: "Восстановить" } ); if (confirmed) { try { const response = await fetch(`/api/notes/${noteId}/unarchive`, { method: "PUT", }); if (!response.ok) { throw new Error("Ошибка восстановления заметки"); } // Удаляем элемент из списка document.querySelector(`[data-note-id="${noteId}"]`)?.remove(); // Проверяем, остались ли заметки const container = document.getElementById("archived-notes-container"); if (container.children.length === 0) { container.innerHTML = 'Архив пуст
'; } showNotification("Заметка восстановлена!", "success"); } catch (error) { console.error("Ошибка:", error); showNotification("Ошибка восстановления заметки", "error"); } } }); }); // Окончательное удаление document.querySelectorAll(".btn-delete-permanent").forEach((btn) => { btn.addEventListener("click", async (e) => { const noteId = e.target.closest("button").dataset.id; const confirmed = await showConfirmModal( "Подтверждение удаления", "Удалить эту заметку НАВСЕГДА? Это действие нельзя отменить!", { confirmType: "danger", confirmText: "Удалить навсегда" } ); if (confirmed) { try { const response = await fetch(`/api/notes/archived/${noteId}`, { method: "DELETE", }); if (!response.ok) { throw new Error("Ошибка удаления заметки"); } // Удаляем элемент из списка document.querySelector(`[data-note-id="${noteId}"]`)?.remove(); // Проверяем, остались ли заметки const container = document.getElementById("archived-notes-container"); if (container.children.length === 0) { container.innerHTML = 'Архив пуст
'; } showNotification("Заметка удалена окончательно", "success"); } catch (error) { console.error("Ошибка:", error); showNotification("Ошибка удаления заметки", "error"); } } }); }); } // Обработчик кнопки "Удалить все" архивные заметки function initDeleteAllArchivedHandler() { const deleteAllBtn = document.getElementById("delete-all-archived"); const modal = document.getElementById("deleteAllArchivedModal"); const closeBtn = document.getElementById("deleteAllArchivedModalClose"); const cancelBtn = document.getElementById("cancelDeleteAllArchived"); const confirmBtn = document.getElementById("confirmDeleteAllArchived"); const passwordInput = document.getElementById("deleteAllPassword"); // Открытие модального окна deleteAllBtn.addEventListener("click", () => { // Проверяем, есть ли архивные заметки const container = document.getElementById("archived-notes-container"); const noteItems = container.querySelectorAll(".archived-note-item"); if (noteItems.length === 0) { showNotification("Архив уже пуст", "info"); return; } // Очищаем поле пароля и открываем модальное окно 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/notes/archived/all", { 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(data.message, "success"); closeModal(); // Перезагружаем архивные заметки loadArchivedNotes(); } 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(); } }); } // Загрузка логов async function loadLogs(reset = false) { if (reset) { logsOffset = 0; hasMoreLogs = true; } const tbody = document.getElementById("logsTableBody"); const loadMoreContainer = document.getElementById("logsLoadMore"); const filterValue = document.getElementById("logTypeFilter").value; if (reset) { tbody.innerHTML = '