- Реализована возможность вставки спойлеров в заметки с помощью нового интерфейса и логики обработки. - Добавлен переключатель для включения/выключения помощи ИИ в настройках пользователя, с проверкой заполненности обязательных полей. - Обновлены API для получения и сохранения настроек AI, включая новую колонку `ai_enabled` в таблице пользователей. - Улучшены стили и обработчики событий для новых элементов интерфейса, включая спойлеры и переключатель AI.
1118 lines
38 KiB
JavaScript
1118 lines
38 KiB
JavaScript
// Кастомное расширение для скрытого текста (спойлеров)
|
||
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 `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
|
||
},
|
||
};
|
||
|
||
// Настройка 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 `<a href="${href}" title="${
|
||
title || ""
|
||
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
|
||
} 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) {
|
||
event.stopPropagation();
|
||
this.classList.toggle("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 = `
|
||
<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 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 =
|
||
'<p style="text-align: center; color: #999;">Загрузка...</p>';
|
||
|
||
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 =
|
||
'<p style="text-align: center; color: #999;">Архив пуст</p>';
|
||
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 = `<div class="archived-note-images">${note.images.length} изображений</div>`;
|
||
}
|
||
|
||
noteDiv.innerHTML = `
|
||
<div class="archived-note-header">
|
||
<span class="archived-note-date">${dateStr}</span>
|
||
<div class="archived-note-actions">
|
||
<button class="btn-restore" data-id="${note.id}" title="Восстановить">
|
||
<span class="iconify" data-icon="mdi:restore"></span> Восстановить
|
||
</button>
|
||
<button class="btn-delete-permanent" data-id="${note.id}" title="Удалить навсегда">
|
||
<span class="iconify" data-icon="mdi:delete-forever"></span> Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="archived-note-content">${preview}</div>
|
||
${imagesHtml}
|
||
`;
|
||
|
||
container.appendChild(noteDiv);
|
||
});
|
||
|
||
// Добавляем обработчики событий
|
||
addArchivedNotesEventListeners();
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки архивных заметок:", error);
|
||
container.innerHTML =
|
||
'<p style="text-align: center; color: #dc3545;">Ошибка загрузки архивных заметок</p>';
|
||
}
|
||
}
|
||
|
||
// Добавление обработчиков для архивных заметок
|
||
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 =
|
||
'<p style="text-align: center; color: #999;">Архив пуст</p>';
|
||
}
|
||
|
||
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 =
|
||
'<p style="text-align: center; color: #999;">Архив пуст</p>';
|
||
}
|
||
|
||
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 =
|
||
'<span class="iconify" data-icon="mdi:loading"></span> Удаление...';
|
||
|
||
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 =
|
||
'<span class="iconify" data-icon="mdi:delete-forever"></span> Удалить все';
|
||
}
|
||
});
|
||
|
||
// Обработка 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 =
|
||
'<tr><td colspan="4" style="text-align: center;">Загрузка...</td></tr>';
|
||
}
|
||
|
||
try {
|
||
let url = `/api/logs?limit=${logsLimit}&offset=${logsOffset}`;
|
||
if (filterValue) {
|
||
url += `&action_type=${filterValue}`;
|
||
}
|
||
|
||
const response = await fetch(url);
|
||
if (!response.ok) {
|
||
throw new Error("Ошибка загрузки логов");
|
||
}
|
||
|
||
const logs = await response.json();
|
||
|
||
if (reset) {
|
||
tbody.innerHTML = "";
|
||
}
|
||
|
||
if (logs.length === 0 && logsOffset === 0) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="4" style="text-align: center; color: #999;">Логов пока нет</td></tr>';
|
||
loadMoreContainer.style.display = "none";
|
||
return;
|
||
}
|
||
|
||
if (logs.length < logsLimit) {
|
||
hasMoreLogs = false;
|
||
}
|
||
|
||
logs.forEach((log) => {
|
||
const row = document.createElement("tr");
|
||
|
||
// Форматируем дату
|
||
const created = new Date(log.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",
|
||
second: "2-digit",
|
||
}).format(created);
|
||
|
||
// Форматируем тип действия
|
||
const actionTypes = {
|
||
login: "Вход",
|
||
logout: "Выход",
|
||
register: "Регистрация",
|
||
note_create: "Создание заметки",
|
||
note_update: "Редактирование",
|
||
note_delete: "Удаление",
|
||
note_pin: "Закрепление",
|
||
note_archive: "Архивирование",
|
||
note_unarchive: "Восстановление",
|
||
note_delete_permanent: "Окончательное удаление",
|
||
profile_update: "Обновление профиля",
|
||
ai_improve: "Улучшение через AI",
|
||
};
|
||
|
||
const actionText = actionTypes[log.action_type] || log.action_type;
|
||
|
||
row.innerHTML = `
|
||
<td>${dateStr}</td>
|
||
<td><span class="log-action-badge log-action-${
|
||
log.action_type
|
||
}">${actionText}</span></td>
|
||
<td>${log.details || "-"}</td>
|
||
<td>${log.ip_address || "-"}</td>
|
||
`;
|
||
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
logsOffset += logs.length;
|
||
|
||
if (hasMoreLogs && logs.length > 0) {
|
||
loadMoreContainer.style.display = "block";
|
||
} else {
|
||
loadMoreContainer.style.display = "none";
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки логов:", error);
|
||
if (reset) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="4" style="text-align: center; color: #dc3545;">Ошибка загрузки логов</td></tr>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Инициализация при загрузке страницы
|
||
document.addEventListener("DOMContentLoaded", async function () {
|
||
// Проверяем аутентификацию
|
||
const isAuth = await checkAuthentication();
|
||
if (!isAuth) return;
|
||
|
||
// Загружаем информацию о пользователе
|
||
await loadUserInfo();
|
||
|
||
// Инициализируем табы
|
||
initTabs();
|
||
|
||
// Инициализируем обработчик удаления всех архивных заметок
|
||
initDeleteAllArchivedHandler();
|
||
|
||
// Обработчик фильтра логов
|
||
document.getElementById("logTypeFilter").addEventListener("change", () => {
|
||
loadLogs(true);
|
||
});
|
||
|
||
// Обработчик кнопки обновления логов
|
||
document.getElementById("refreshLogs").addEventListener("click", () => {
|
||
loadLogs(true);
|
||
});
|
||
|
||
// Обработчик кнопки "Загрузить еще"
|
||
document.getElementById("loadMoreLogsBtn").addEventListener("click", () => {
|
||
loadLogs(false);
|
||
});
|
||
|
||
// Обработчик кнопки сохранения внешнего вида
|
||
if (updateAppearanceBtn) {
|
||
updateAppearanceBtn.addEventListener("click", async function () {
|
||
const accentColor = settingsAccentColorInput.value;
|
||
|
||
try {
|
||
const response = await fetch("/api/user/profile", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
accent_color: accentColor,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка обновления цветового акцента");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Применяем новый цветовой акцент
|
||
document.documentElement.style.setProperty(
|
||
"--accent-color",
|
||
accentColor
|
||
);
|
||
|
||
showNotification(
|
||
result.message || "Цветовой акцент успешно обновлен",
|
||
"success"
|
||
);
|
||
} catch (error) {
|
||
console.error("Ошибка обновления цветового акцента:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
});
|
||
}
|
||
|
||
// Обработчики для цветовой палитры
|
||
function setupAppearanceColorPicker() {
|
||
const colorOptions = document.querySelectorAll(
|
||
"#appearance-tab .color-option"
|
||
);
|
||
colorOptions.forEach((option) => {
|
||
option.addEventListener("click", function () {
|
||
const selectedColor = this.dataset.color;
|
||
settingsAccentColorInput.value = selectedColor;
|
||
updateColorPickerSelection(selectedColor);
|
||
});
|
||
});
|
||
|
||
// Обработчик для input color
|
||
if (settingsAccentColorInput) {
|
||
settingsAccentColorInput.addEventListener("input", function () {
|
||
updateColorPickerSelection(this.value);
|
||
});
|
||
}
|
||
}
|
||
|
||
setupAppearanceColorPicker();
|
||
|
||
// Обработчик кнопки сохранения AI настроек
|
||
const updateAiSettingsBtn = document.getElementById("updateAiSettingsBtn");
|
||
if (updateAiSettingsBtn) {
|
||
updateAiSettingsBtn.addEventListener("click", async function () {
|
||
const apiKey = document.getElementById("openai-api-key").value.trim();
|
||
const baseUrl = document.getElementById("openai-base-url").value.trim();
|
||
const model = document.getElementById("openai-model").value.trim();
|
||
|
||
if (!apiKey) {
|
||
showNotification("API ключ обязателен", "error");
|
||
return;
|
||
}
|
||
|
||
if (!baseUrl) {
|
||
showNotification("Base URL обязателен", "error");
|
||
return;
|
||
}
|
||
|
||
if (!model) {
|
||
showNotification("Название модели обязательно", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/user/ai-settings", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
openai_api_key: apiKey,
|
||
openai_base_url: baseUrl,
|
||
openai_model: model,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка сохранения AI настроек");
|
||
}
|
||
|
||
const result = await response.json();
|
||
showNotification(
|
||
result.message || "AI настройки успешно сохранены",
|
||
"success"
|
||
);
|
||
|
||
// Обновляем состояние переключателя после сохранения
|
||
updateAiToggleState();
|
||
} catch (error) {
|
||
console.error("Ошибка сохранения AI настроек:", error);
|
||
showNotification(error.message, "error");
|
||
}
|
||
});
|
||
}
|
||
|
||
// Обработчик переключателя AI
|
||
const aiEnabledToggle = document.getElementById("ai-enabled-toggle");
|
||
if (aiEnabledToggle) {
|
||
aiEnabledToggle.addEventListener("change", async function () {
|
||
// Проверяем заполнены ли настройки перед включением
|
||
if (aiEnabledToggle.checked && !checkAiSettingsFilled()) {
|
||
aiEnabledToggle.checked = false;
|
||
showNotification("Сначала заполните все AI настройки", "warning");
|
||
return;
|
||
}
|
||
|
||
const isEnabled = aiEnabledToggle.checked;
|
||
|
||
try {
|
||
const response = await fetch("/api/user/ai-settings", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
ai_enabled: isEnabled ? 1 : 0,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Ошибка сохранения настройки AI");
|
||
}
|
||
|
||
const result = await response.json();
|
||
showNotification(
|
||
isEnabled ? "Помощь ИИ включена" : "Помощь ИИ отключена",
|
||
"success"
|
||
);
|
||
|
||
// Сохраняем в localStorage для быстрого доступа в app.js
|
||
localStorage.setItem("ai_enabled", isEnabled ? "1" : "0");
|
||
} catch (error) {
|
||
console.error("Ошибка сохранения настройки AI:", error);
|
||
showNotification(error.message, "error");
|
||
// Откатываем изменения при ошибке
|
||
aiEnabledToggle.checked = !isEnabled;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Отслеживаем изменения в полях AI настроек
|
||
const apiKeyInput = document.getElementById("openai-api-key");
|
||
const baseUrlInput = document.getElementById("openai-base-url");
|
||
const modelInput = document.getElementById("openai-model");
|
||
|
||
[apiKeyInput, baseUrlInput, modelInput].forEach((input) => {
|
||
if (input) {
|
||
input.addEventListener("input", updateAiToggleState);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Проверка заполнения AI настроек
|
||
function checkAiSettingsFilled() {
|
||
const apiKey = document.getElementById("openai-api-key").value.trim();
|
||
const baseUrl = document.getElementById("openai-base-url").value.trim();
|
||
const model = document.getElementById("openai-model").value.trim();
|
||
|
||
return apiKey && baseUrl && model;
|
||
}
|
||
|
||
// Обновление состояния переключателя AI
|
||
function updateAiToggleState() {
|
||
const aiEnabledToggle = document.getElementById("ai-enabled-toggle");
|
||
const toggleLabel = document.querySelector(".ai-toggle-label");
|
||
const toggleDesc = document.querySelector(".toggle-text-desc");
|
||
|
||
if (!aiEnabledToggle) return;
|
||
|
||
const isFilled = checkAiSettingsFilled();
|
||
|
||
if (isFilled) {
|
||
aiEnabledToggle.disabled = false;
|
||
toggleLabel.classList.remove("disabled");
|
||
toggleDesc.textContent =
|
||
'Показывать кнопку "Помощь ИИ" в редакторах заметок';
|
||
} else {
|
||
aiEnabledToggle.disabled = true;
|
||
aiEnabledToggle.checked = false;
|
||
toggleLabel.classList.add("disabled");
|
||
toggleDesc.textContent =
|
||
"Сначала заполните API Key, Base URL и Модель ниже";
|
||
}
|
||
}
|
||
|
||
// Загрузка AI настроек
|
||
async function loadAiSettings() {
|
||
try {
|
||
const response = await fetch("/api/user/ai-settings");
|
||
if (response.ok) {
|
||
const settings = await response.json();
|
||
const apiKeyInput = document.getElementById("openai-api-key");
|
||
const baseUrlInput = document.getElementById("openai-base-url");
|
||
const modelInput = document.getElementById("openai-model");
|
||
const aiEnabledToggle = document.getElementById("ai-enabled-toggle");
|
||
|
||
if (apiKeyInput) {
|
||
apiKeyInput.value = settings.openai_api_key || "";
|
||
}
|
||
if (baseUrlInput) {
|
||
baseUrlInput.value = settings.openai_base_url || "";
|
||
}
|
||
if (modelInput) {
|
||
modelInput.value = settings.openai_model || "";
|
||
}
|
||
if (aiEnabledToggle) {
|
||
aiEnabledToggle.checked = settings.ai_enabled === 1;
|
||
}
|
||
|
||
// Проверяем и обновляем состояние переключателя
|
||
updateAiToggleState();
|
||
|
||
// Сохраняем в localStorage для быстрого доступа в app.js
|
||
localStorage.setItem("ai_enabled", settings.ai_enabled ? "1" : "0");
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки AI настроек:", error);
|
||
}
|
||
}
|