Добавлены функции для работы с AI настройками и улучшения текста

- Реализованы API для сохранения и получения AI настроек пользователя, включая OpenAI API ключ, базовый URL и модель.
- Добавлена возможность окончательного удаления всех архивных заметок с подтверждением пароля.
- Внедрена функция улучшения текста через AI, с обработкой запросов к OpenAI API.
- Обновлены интерфейсы для работы с AI настройками и добавлены уведомления для улучшения пользовательского опыта.
This commit is contained in:
Fovway 2025-10-26 14:45:02 +07:00
parent 323f96a502
commit d89a617264
10 changed files with 1978 additions and 110 deletions

View File

@ -2,6 +2,198 @@
const AVATAR_CACHE_KEY = "avatar_cache";
const AVATAR_TIMESTAMP_KEY = "avatar_timestamp";
// Система стека уведомлений
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 testNotificationStack() {
showNotification("Первое уведомление", "success");
setTimeout(() => showNotification("Второе уведомление", "info"), 500);
setTimeout(() => showNotification("Третье уведомление", "warning"), 1000);
setTimeout(() => showNotification("Четвертое уведомление", "error"), 1500);
}
// Тестовая функция для демонстрации модальных окон (можно удалить в продакшене)
async function testModalWindows() {
// Тест обычного модального окна
const result1 = await showConfirmModal(
"Тест обычного окна",
"Это тестовое модальное окно с обычной кнопкой",
{ confirmText: "Подтвердить" }
);
console.log("Результат обычного окна:", result1);
// Тест опасного модального окна
const result2 = await showConfirmModal(
"Тест опасного окна",
"Это тестовое модальное окно с красной кнопкой",
{ confirmType: "danger", confirmText: "Удалить" }
);
console.log("Результат опасного окна:", result2);
}
// Универсальная функция для модальных окон подтверждения
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">&times;</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);
});
}
// Функция для кэширования аватарки
async function cacheAvatar(avatarUrl) {
try {
@ -88,6 +280,7 @@ const linkBtn = document.getElementById("linkBtn");
const checkboxBtn = document.getElementById("checkboxBtn");
const imageBtn = document.getElementById("imageBtn");
const previewBtn = document.getElementById("previewBtn");
const aiImproveBtn = document.getElementById("aiImproveBtn");
// Кнопка настроек
const settingsBtn = document.getElementById("settings-btn");
@ -884,6 +1077,56 @@ previewBtn.addEventListener("click", function () {
togglePreview();
});
// Обработчик для кнопки улучшения через AI
aiImproveBtn.addEventListener("click", async function () {
const content = noteInput.value.trim();
if (!content) {
showNotification("Введите текст для улучшения", "warning");
return;
}
// Показываем индикатор загрузки
const originalTitle = aiImproveBtn.title;
aiImproveBtn.disabled = true;
aiImproveBtn.innerHTML =
'<span class="iconify" data-icon="mdi:loading" style="animation: spin 1s linear infinite;"></span>';
aiImproveBtn.title = "Обработка...";
try {
const response = await fetch("/api/ai/improve", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: content }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ошибка улучшения текста");
}
// Заменяем текст на улучшенный
noteInput.value = data.improvedText;
// Авторасширяем textarea
autoExpandTextarea(noteInput);
showNotification("Текст успешно улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification(error.message || "Ошибка улучшения текста", "error");
} finally {
// Восстанавливаем кнопку
aiImproveBtn.disabled = false;
aiImproveBtn.innerHTML =
'<span class="iconify" data-icon="mdi:robot"></span>';
aiImproveBtn.title = originalTitle;
}
});
// Функция переключения режима предпросмотра
function togglePreview() {
isPreviewMode = !isPreviewMode;
@ -934,7 +1177,10 @@ imageInput.addEventListener("change", function (event) {
if (file.type.startsWith("image/")) {
// Проверяем размер файла (максимум 10MB)
if (file.size > 10 * 1024 * 1024) {
alert(`Файл "${file.name}" слишком большой. Максимальный размер: 10MB`);
showNotification(
`Файл "${file.name}" слишком большой. Максимальный размер: 10MB`,
"error"
);
return;
}
@ -949,7 +1195,7 @@ imageInput.addEventListener("change", function (event) {
addedCount++;
}
} else {
alert(`Файл "${file.name}" не является изображением`);
showNotification(`Файл "${file.name}" не является изображением`, "error");
}
});
@ -1041,7 +1287,7 @@ function updateImagePreview() {
reader.onerror = function () {
console.error("Ошибка чтения файла:", file.name);
alert(`Ошибка чтения файла: ${file.name}`);
showNotification(`Ошибка чтения файла: ${file.name}`, "error");
};
reader.readAsDataURL(file);
@ -1132,7 +1378,7 @@ async function uploadImages(noteId) {
}
// Показываем ошибку пользователю
alert(`Ошибка загрузки изображений: ${error.message}`);
showNotification(`Ошибка загрузки изображений: ${error.message}`, "error");
return [];
}
}
@ -1546,7 +1792,7 @@ function addNoteEventListeners() {
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка изменения закрепления");
showNotification("Ошибка изменения закрепления", "error");
}
});
});
@ -1555,11 +1801,12 @@ function addNoteEventListeners() {
document.querySelectorAll("#archiveBtn").forEach((btn) => {
btn.addEventListener("click", async function (event) {
const noteId = event.target.closest("#archiveBtn").dataset.id;
if (
confirm(
"Архивировать эту заметку? Её можно будет восстановить из настроек."
)
) {
const confirmed = await showConfirmModal(
"Подтверждение архивирования",
"Архивировать эту заметку? Её можно будет восстановить из настроек.",
{ confirmText: "Архивировать" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/${noteId}/archive`, {
method: "PUT",
@ -1573,7 +1820,7 @@ function addNoteEventListeners() {
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка архивирования заметки");
showNotification("Ошибка архивирования заметки", "error");
}
}
});
@ -1583,7 +1830,12 @@ function addNoteEventListeners() {
document.querySelectorAll("#deleteBtn").forEach((btn) => {
btn.addEventListener("click", async function (event) {
const noteId = event.target.dataset.id;
if (confirm("Вы уверены, что хотите удалить эту заметку?")) {
const confirmed = await showConfirmModal(
"Подтверждение удаления",
"Вы уверены, что хотите удалить эту заметку?",
{ confirmType: "danger", confirmText: "Удалить" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: "DELETE",
@ -1597,7 +1849,7 @@ function addNoteEventListeners() {
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка удаления заметки");
showNotification("Ошибка удаления заметки", "error");
}
}
});
@ -1976,6 +2228,17 @@ function addNoteEventListeners() {
const saveButtonContainer = document.createElement("div");
saveButtonContainer.classList.add("save-button-container");
// Контейнер для кнопок действий
const actionButtons = document.createElement("div");
actionButtons.classList.add("action-buttons");
// Кнопка ИИ помощи
const aiImproveEditBtn = document.createElement("button");
aiImproveEditBtn.classList.add("btnSave", "btnAI");
aiImproveEditBtn.innerHTML =
'<span class="iconify" data-icon="mdi:robot"></span> Помощь ИИ';
aiImproveEditBtn.title = "Улучшить или создать текст через ИИ";
// Кнопка сохранить
const saveEditBtn = document.createElement("button");
saveEditBtn.textContent = "Сохранить";
@ -1985,15 +2248,18 @@ function addNoteEventListeners() {
const cancelEditBtn = document.createElement("button");
cancelEditBtn.textContent = "Отмена";
cancelEditBtn.classList.add("btnSave");
cancelEditBtn.style.marginLeft = "8px";
// Подсказка о горячей клавише
const saveHint = document.createElement("span");
saveHint.classList.add("save-hint");
saveHint.textContent = "или нажмите Alt + Enter";
saveButtonContainer.appendChild(saveEditBtn);
saveButtonContainer.appendChild(cancelEditBtn);
// Добавляем кнопки в контейнер действий
actionButtons.appendChild(aiImproveEditBtn);
actionButtons.appendChild(saveEditBtn);
actionButtons.appendChild(cancelEditBtn);
saveButtonContainer.appendChild(actionButtons);
saveButtonContainer.appendChild(saveHint);
// Функция обновления превью изображений для режима редактирования
@ -2103,7 +2369,7 @@ function addNoteEventListeners() {
await loadNotes(true);
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка сохранения заметки");
showNotification("Ошибка сохранения заметки", "error");
}
}
};
@ -2115,7 +2381,11 @@ function addNoteEventListeners() {
const hasNewImages = editSelectedImages.length > 0;
if (hasTextChanges || hasNewImages) {
const ok = confirm("Отменить изменения?");
const ok = await showConfirmModal(
"Подтверждение отмены",
"Отменить изменения?",
{ confirmText: "Отменить" }
);
if (!ok) return;
}
@ -2269,6 +2539,57 @@ function addNoteEventListeners() {
});
});
// Обработчик кнопки ИИ для редактирования
aiImproveEditBtn.addEventListener("click", async function () {
const textarea = noteElement.querySelector(".edit-note-textarea");
const content = textarea.value.trim();
if (!content) {
showNotification("Введите текст для улучшения", "warning");
return;
}
// Показываем индикатор загрузки
const originalHTML = aiImproveEditBtn.innerHTML;
const originalTitle = aiImproveEditBtn.title;
aiImproveEditBtn.disabled = true;
aiImproveEditBtn.innerHTML =
'<span class="iconify" data-icon="mdi:loading" style="animation: spin 1s linear infinite;"></span> Обработка...';
aiImproveEditBtn.title = "Обработка...";
try {
const response = await fetch("/api/ai/improve", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: content }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ошибка улучшения текста");
}
// Заменяем текст на улучшенный
textarea.value = data.improvedText;
// Авторасширяем textarea
autoExpandTextarea(textarea);
showNotification("Текст успешно улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
showNotification(error.message || "Ошибка улучшения текста", "error");
} finally {
// Восстанавливаем кнопку
aiImproveEditBtn.disabled = false;
aiImproveEditBtn.innerHTML = originalHTML;
aiImproveEditBtn.title = originalTitle;
}
});
// Обработчик сохранения редактирования
saveEditBtn.addEventListener("click", saveEditNote);
@ -2456,7 +2777,7 @@ function addCheckboxEventListeners() {
console.error("Ошибка автосохранения чекбокса:", error);
// Если не удалось сохранить, возвращаем чекбокс в прежнее состояние
this.checked = !this.checked;
alert("Ошибка сохранения изменений");
showNotification("Ошибка сохранения изменений", "error");
}
}
};
@ -2588,7 +2909,7 @@ async function saveNote() {
savingIndicator.remove();
}
alert("Ошибка сохранения заметки");
showNotification("Ошибка сохранения заметки", "error");
}
}
}

View File

@ -172,6 +172,102 @@
<!-- PWA Service Worker Registration -->
<script>
// Универсальная функция для модальных окон подтверждения
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">&times;</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);
});
}
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
@ -182,15 +278,18 @@
// Проверяем обновления
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
newWorker.addEventListener("statechange", async () => {
if (
newWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
// Новый контент доступен, можно показать уведомление
if (
confirm("Доступна новая версия приложения. Обновить?")
) {
const confirmed = await showConfirmModal(
"Обновление приложения",
"Доступна новая версия приложения. Обновить?",
{ confirmText: "Обновить" }
);
if (confirmed) {
newWorker.postMessage({ type: "SKIP_WAITING" });
window.location.reload();
}

View File

@ -400,7 +400,17 @@
</div>
<div class="save-button-container">
<button class="btnSave" id="saveBtn">Сохранить</button>
<div class="action-buttons">
<button
class="btnSave btnAI"
id="aiImproveBtn"
title="Улучшить или создать текст через ИИ"
>
<span class="iconify" data-icon="mdi:robot"></span>
Помощь ИИ
</button>
<button class="btnSave" id="saveBtn">Сохранить</button>
</div>
<span class="save-hint">или нажмите Alt + Enter</span>
</div>
</div>

View File

@ -191,8 +191,6 @@
</button>
</div>
</div>
<div id="messageContainer" class="message-container"></div>
</div>
<div class="footer">

View File

@ -1,3 +1,168 @@
// Система стека уведомлений
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">&times;</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");
@ -72,7 +237,6 @@ const currentPasswordInput = document.getElementById("currentPassword");
const newPasswordInput = document.getElementById("newPassword");
const confirmPasswordInput = document.getElementById("confirmPassword");
const changePasswordBtn = document.getElementById("changePasswordBtn");
const messageContainer = document.getElementById("messageContainer");
// Кэширование аватарки
const AVATAR_CACHE_KEY = "avatar_cache";
@ -185,17 +349,6 @@ function initLazyLoading() {
}
}
// Функция для показа сообщений
function showMessage(message, type = "success") {
messageContainer.textContent = message;
messageContainer.className = `message-container ${type}`;
messageContainer.style.display = "block";
setTimeout(() => {
messageContainer.style.display = "none";
}, 5000);
}
// Загрузка данных профиля
async function loadProfile() {
try {
@ -246,7 +399,7 @@ async function loadProfile() {
}
} catch (error) {
console.error("Ошибка загрузки профиля:", error);
showMessage("Ошибка загрузки данных профиля", "error");
showNotification("Ошибка загрузки данных профиля", "error");
}
}
@ -257,14 +410,17 @@ avatarInput.addEventListener("change", async function (event) {
// Проверка размера файла (5MB)
if (file.size > 5 * 1024 * 1024) {
showMessage("Файл слишком большой. Максимальный размер: 5 МБ", "error");
showNotification(
"Файл слишком большой. Максимальный размер: 5 МБ",
"error"
);
return;
}
// Проверка типа файла
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif"];
if (!allowedTypes.includes(file.type)) {
showMessage(
showNotification(
"Недопустимый формат файла. Используйте JPG, PNG или GIF",
"error"
);
@ -300,10 +456,10 @@ avatarInput.addEventListener("change", async function (event) {
}
});
showMessage("Аватарка успешно загружена", "success");
showNotification("Аватарка успешно загружена", "success");
} catch (error) {
console.error("Ошибка загрузки аватарки:", error);
showMessage(error.message, "error");
showNotification(error.message, "error");
}
// Сбрасываем input для возможности повторной загрузки того же файла
@ -312,7 +468,12 @@ avatarInput.addEventListener("change", async function (event) {
// Обработчик удаления аватарки
deleteAvatarBtn.addEventListener("click", async function () {
if (!confirm("Вы уверены, что хотите удалить аватарку?")) {
const confirmed = await showConfirmModal(
"Подтверждение удаления",
"Вы уверены, что хотите удалить аватарку?",
{ confirmType: "danger", confirmText: "Удалить" }
);
if (!confirmed) {
return;
}
@ -334,10 +495,10 @@ deleteAvatarBtn.addEventListener("click", async function () {
// Очищаем кэш аватарки
clearAvatarCache();
showMessage("Аватарка успешно удалена", "success");
showNotification("Аватарка успешно удалена", "success");
} catch (error) {
console.error("Ошибка удаления аватарки:", error);
showMessage(error.message, "error");
showNotification(error.message, "error");
}
});
@ -348,17 +509,17 @@ updateProfileBtn.addEventListener("click", async function () {
// Валидация
if (!username) {
showMessage("Логин не может быть пустым", "error");
showNotification("Логин не может быть пустым", "error");
return;
}
if (username.length < 3) {
showMessage("Логин должен быть не менее 3 символов", "error");
showNotification("Логин должен быть не менее 3 символов", "error");
return;
}
if (email && !isValidEmail(email)) {
showMessage("Некорректный email адрес", "error");
showNotification("Некорректный email адрес", "error");
return;
}
@ -381,10 +542,10 @@ updateProfileBtn.addEventListener("click", async function () {
const result = await response.json();
showMessage(result.message || "Профиль успешно обновлен", "success");
showNotification(result.message || "Профиль успешно обновлен", "success");
} catch (error) {
console.error("Ошибка обновления профиля:", error);
showMessage(error.message, "error");
showNotification(error.message, "error");
}
});
@ -396,22 +557,22 @@ changePasswordBtn.addEventListener("click", async function () {
// Валидация
if (!currentPassword) {
showMessage("Введите текущий пароль", "error");
showNotification("Введите текущий пароль", "error");
return;
}
if (!newPassword) {
showMessage("Введите новый пароль", "error");
showNotification("Введите новый пароль", "error");
return;
}
if (newPassword.length < 6) {
showMessage("Новый пароль должен быть не менее 6 символов", "error");
showNotification("Новый пароль должен быть не менее 6 символов", "error");
return;
}
if (newPassword !== confirmPassword) {
showMessage("Новый пароль и подтверждение не совпадают", "error");
showNotification("Новый пароль и подтверждение не совпадают", "error");
return;
}
@ -439,10 +600,10 @@ changePasswordBtn.addEventListener("click", async function () {
newPasswordInput.value = "";
confirmPasswordInput.value = "";
showMessage(result.message || "Пароль успешно изменен", "success");
showNotification(result.message || "Пароль успешно изменен", "success");
} catch (error) {
console.error("Ошибка изменения пароля:", error);
showMessage(error.message, "error");
showNotification(error.message, "error");
}
});

View File

@ -1,3 +1,99 @@
// Универсальная функция для модальных окон подтверждения
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">&times;</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);
});
}
// PWA Service Worker Registration
class PWAManager {
constructor() {
@ -61,8 +157,13 @@ class PWAManager {
}
// Показ уведомления об обновлении
showUpdateNotification() {
if (confirm("Доступна новая версия приложения. Обновить?")) {
async showUpdateNotification() {
const confirmed = await showConfirmModal(
"Обновление приложения",
"Доступна новая версия приложения. Обновить?",
{ confirmText: "Обновить" }
);
if (confirmed) {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: "SKIP_WAITING",

View File

@ -94,6 +94,9 @@
<button class="settings-tab active" data-tab="appearance">
<span class="iconify" data-icon="mdi:palette"></span> Внешний вид
</button>
<button class="settings-tab" data-tab="ai">
<span class="iconify" data-icon="mdi:robot"></span> AI настройки
</button>
<button class="settings-tab" data-tab="archive">
<span class="iconify" data-icon="mdi:archive"></span> Архив заметок
</button>
@ -161,9 +164,81 @@
</button>
</div>
<!-- AI настройки -->
<div class="tab-content" id="ai-tab">
<h3>Настройки AI</h3>
<div class="form-group">
<label for="openai-api-key">OpenAI API Key</label>
<input
type="password"
id="openai-api-key"
placeholder="sk-..."
class="form-input"
/>
<p style="color: #666; font-size: 12px; margin-top: 5px">
Введите ваш OpenAI API ключ
</p>
</div>
<div class="form-group">
<label for="openai-base-url">OpenAI Base URL</label>
<input
type="text"
id="openai-base-url"
placeholder="https://api.openai.com/v1"
class="form-input"
/>
<p style="color: #666; font-size: 12px; margin-top: 5px">
URL для API запросов (например, https://api.openai.com/v1)
</p>
</div>
<div class="form-group">
<label for="openai-model">Модель</label>
<input
type="text"
id="openai-model"
placeholder="gpt-3.5-turbo"
class="form-input"
/>
<p style="color: #666; font-size: 12px; margin-top: 5px">
Название модели (например, gpt-4, deepseek/deepseek-chat).
<a
href="https://openrouter.ai/models"
target="_blank"
style="color: var(--accent-color)"
>
Список доступных моделей
</a>
</p>
</div>
<button id="updateAiSettingsBtn" class="btnSave">
Сохранить AI настройки
</button>
</div>
<!-- Архив заметок -->
<div class="tab-content" id="archive-tab">
<h3>Архивные заметки</h3>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
"
>
<h3>Архивные заметки</h3>
<button
id="delete-all-archived"
class="btn-danger"
style="font-size: 14px; padding: 8px 16px"
>
<span class="iconify" data-icon="mdi:delete-sweep"></span> Удалить
все
</button>
</div>
<p style="color: #666; font-size: 14px; margin-bottom: 20px">
Архивированные заметки можно восстановить или удалить окончательно
</p>
@ -193,6 +268,7 @@
Окончательное удаление
</option>
<option value="profile_update">Обновление профиля</option>
<option value="ai_improve">Улучшение через AI</option>
</select>
<button id="refreshLogs" class="btnSave">
<span class="iconify" data-icon="mdi:refresh"></span> Обновить
@ -227,12 +303,58 @@
</div>
</div>
<div id="settings-message-container" class="message-container"></div>
<div class="footer">
<p>Создатель: <span>Fovway</span></p>
</div>
<!-- Модальное окно подтверждения удаления всех архивных заметок -->
<div id="deleteAllArchivedModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Подтверждение удаления</h3>
<span class="modal-close" id="deleteAllArchivedModalClose"
>&times;</span
>
</div>
<div class="modal-body">
<p style="color: #dc3545; font-weight: bold; margin-bottom: 15px">
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
</p>
<p style="margin-bottom: 20px">
Вы действительно хотите удалить ВСЕ архивные заметки? Все заметки и
их изображения будут удалены навсегда.
</p>
<div style="margin-bottom: 15px">
<label
for="deleteAllPassword"
style="display: block; margin-bottom: 5px; font-weight: bold"
>
Введите пароль для подтверждения:
</label>
<input
type="password"
id="deleteAllPassword"
placeholder="Пароль от аккаунта"
class="modal-password-input"
/>
</div>
</div>
<div class="modal-footer">
<button
id="confirmDeleteAllArchived"
class="btn-danger"
style="margin-right: 10px"
>
<span class="iconify" data-icon="mdi:delete-forever"></span> Удалить
все
</button>
<button id="cancelDeleteAllArchived" class="btn-secondary">
Отмена
</button>
</div>
</div>
</div>
<!-- Модальное окно для просмотра изображений -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span>

View File

@ -139,18 +139,169 @@ function updateColorPickerSelection(selectedColor) {
});
}
// Функция для показа сообщений
function showSettingsMessage(message, type = "success") {
const container = document.getElementById("settings-message-container");
if (container) {
container.textContent = message;
container.className = `message-container ${type}`;
container.style.display = "block";
// Система стека уведомлений
let notificationStack = [];
setTimeout(() => {
container.style.display = "none";
}, 5000);
}
// Универсальная функция для показа уведомлений
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">&times;</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);
});
}
// Переключение табов
@ -177,6 +328,8 @@ function initTabs() {
loadLogs(true);
} else if (tabName === "appearance") {
// Данные внешнего вида уже загружены в loadUserInfo()
} else if (tabName === "ai") {
loadAiSettings();
}
});
});
@ -264,7 +417,12 @@ function addArchivedNotesEventListeners() {
document.querySelectorAll(".btn-restore").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const noteId = e.target.closest("button").dataset.id;
if (confirm("Восстановить эту заметку из архива?")) {
const confirmed = await showConfirmModal(
"Подтверждение восстановления",
"Восстановить эту заметку из архива?",
{ confirmText: "Восстановить" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/${noteId}/unarchive`, {
method: "PUT",
@ -284,10 +442,10 @@ function addArchivedNotesEventListeners() {
'<p style="text-align: center; color: #999;">Архив пуст</p>';
}
alert("Заметка восстановлена!");
showNotification("Заметка восстановлена!", "success");
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка восстановления заметки");
showNotification("Ошибка восстановления заметки", "error");
}
}
});
@ -297,9 +455,12 @@ function addArchivedNotesEventListeners() {
document.querySelectorAll(".btn-delete-permanent").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const noteId = e.target.closest("button").dataset.id;
if (
confirm("Удалить эту заметку НАВСЕГДА? Это действие нельзя отменить!")
) {
const confirmed = await showConfirmModal(
"Подтверждение удаления",
"Удалить эту заметку НАВСЕГДА? Это действие нельзя отменить!",
{ confirmType: "danger", confirmText: "Удалить навсегда" }
);
if (confirmed) {
try {
const response = await fetch(`/api/notes/archived/${noteId}`, {
method: "DELETE",
@ -319,16 +480,116 @@ function addArchivedNotesEventListeners() {
'<p style="text-align: center; color: #999;">Архив пуст</p>';
}
alert("Заметка удалена окончательно");
showNotification("Заметка удалена окончательно", "success");
} catch (error) {
console.error("Ошибка:", error);
alert("Ошибка удаления заметки");
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) {
@ -400,6 +661,7 @@ async function loadLogs(reset = false) {
note_unarchive: "Восстановление",
note_delete_permanent: "Окончательное удаление",
profile_update: "Обновление профиля",
ai_improve: "Улучшение через AI",
};
const actionText = actionTypes[log.action_type] || log.action_type;
@ -444,6 +706,9 @@ document.addEventListener("DOMContentLoaded", async function () {
// Инициализируем табы
initTabs();
// Инициализируем обработчик удаления всех архивных заметок
initDeleteAllArchivedHandler();
// Обработчик фильтра логов
document.getElementById("logTypeFilter").addEventListener("change", () => {
loadLogs(true);
@ -488,13 +753,13 @@ document.addEventListener("DOMContentLoaded", async function () {
accentColor
);
showSettingsMessage(
showNotification(
result.message || "Цветовой акцент успешно обновлен",
"success"
);
} catch (error) {
console.error("Ошибка обновления цветового акцента:", error);
showSettingsMessage(error.message, "error");
showNotification(error.message, "error");
}
});
}
@ -521,4 +786,82 @@ document.addEventListener("DOMContentLoaded", async function () {
}
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"
);
} catch (error) {
console.error("Ошибка сохранения AI настроек:", error);
showNotification(error.message, "error");
}
});
}
});
// Загрузка 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");
if (apiKeyInput) {
apiKeyInput.value = settings.openai_api_key || "";
}
if (baseUrlInput) {
baseUrlInput.value = settings.openai_base_url || "";
}
if (modelInput) {
modelInput.value = settings.openai_model || "";
}
}
} catch (error) {
console.error("Ошибка загрузки AI настроек:", error);
}
}

View File

@ -575,11 +575,38 @@ textarea:focus {
}
.save-button-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.action-buttons {
display: flex;
align-items: center;
gap: 10px;
}
.btnAI {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
gap: 6px;
}
.btnAI:hover {
background: linear-gradient(135deg, #5568d3 0%, #5a3f7d 100%);
transform: translateY(-2px);
}
.btnAI:active {
transform: translateY(0);
}
.btnAI .iconify {
font-size: 18px;
}
.save-hint {
font-size: 12px;
color: #999;
@ -1120,43 +1147,54 @@ textarea:focus {
border-top: 1px solid var(--border-primary);
}
.message-container {
/* Стили для уведомлений */
.notification {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
transform: translateX(-50%) translateY(-100%);
padding: 12px 20px;
border-radius: 8px;
text-align: center;
display: none;
z-index: 10000;
min-width: 300px;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
color: white;
font-weight: 500;
z-index: 10000;
max-width: 350px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, top 0.3s ease;
font-size: 14px;
line-height: 1.4;
word-wrap: break-word;
}
.notification-success {
background-color: #28a745;
}
.notification-error {
background-color: #dc3545;
}
.notification-warning {
background-color: #ffc107;
color: #000;
}
.notification-info {
background-color: #007bff;
}
/* Адаптивность для мобильных устройств */
@media (max-width: 768px) {
.message-container {
min-width: 90vw;
max-width: 90vw;
left: 5vw;
transform: none;
padding: 10px 15px;
.notification {
left: 10px;
right: 10px;
max-width: none;
transform: translateY(-100%);
}
}
.message-container.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message-container.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
.notification.show {
transform: translateY(0);
}
}
.back-btn {
@ -1855,8 +1893,16 @@ textarea:focus {
flex-direction: column;
}
.btnSave {
.action-buttons {
width: 100%;
flex-direction: column;
}
.btnSave,
.btnAI {
width: 100%;
text-align: center;
justify-content: center;
}
/* Адаптируем footer */
@ -2159,6 +2205,135 @@ textarea:focus {
color: #bbb;
}
/* Модальное окно подтверждения */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.modal-content {
background-color: #fff;
margin: 10% auto;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalFadeIn 0.3s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 25px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 20px;
}
.modal-close {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
color: #666;
}
.modal-body {
padding: 25px;
color: #555;
line-height: 1.5;
}
.modal-footer {
padding: 20px 25px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Темная тема для модального окна */
[data-theme="dark"] .modal-content {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
[data-theme="dark"] .modal-header {
border-bottom-color: var(--border-primary);
}
[data-theme="dark"] .modal-header h3 {
color: var(--text-primary);
}
[data-theme="dark"] .modal-close {
color: var(--text-secondary);
}
[data-theme="dark"] .modal-close:hover {
color: var(--text-primary);
}
[data-theme="dark"] .modal-body {
color: var(--text-secondary);
}
[data-theme="dark"] .modal-footer {
border-top-color: var(--border-primary);
}
/* Стили для поля пароля в модальном окне */
.modal-password-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-secondary);
border-radius: 4px;
font-size: 16px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
box-sizing: border-box;
transition: all 0.3s ease;
}
.modal-password-input:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.25);
}
.modal-password-input::placeholder {
color: var(--text-muted);
}
/* Адаптивность для изображений */
@media (max-width: 768px) {
.image-preview-list {
@ -2373,6 +2548,74 @@ textarea:focus {
color: white;
}
/* Стили для новых кнопок */
.btn-primary {
background-color: #007bff;
color: white;
border: 1px solid #007bff;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary:hover {
background-color: #0056b3;
border-color: #004085;
}
.btn-primary:disabled {
background-color: #6c757d;
border-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.btn-danger {
background-color: #dc3545;
color: white;
border: 1px solid #dc3545;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-danger:disabled {
background-color: #6c757d;
border-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background-color: var(--bg-quaternary);
}
.archived-note-content {
font-size: 14px;
color: var(--text-primary);
@ -2569,3 +2812,18 @@ textarea:focus {
width: 100%;
}
}
/* Анимация загрузки для AI кнопки */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.log-action-ai_improve {
background: #d1ecf1;
color: #0c5460;
}

455
server.js
View File

@ -9,6 +9,8 @@ const rateLimit = require("express-rate-limit");
const bodyParser = require("body-parser");
const multer = require("multer");
const fs = require("fs");
const https = require("https");
const http = require("http");
require("dotenv").config();
const app = express();
@ -398,6 +400,51 @@ function runMigrations() {
}
);
}
// Проверяем существование колонок для AI настроек
const hasOpenaiApiKey = columns.some(
(col) => col.name === "openai_api_key"
);
const hasOpenaiBaseUrl = columns.some(
(col) => col.name === "openai_base_url"
);
const hasOpenaiModel = columns.some((col) => col.name === "openai_model");
if (!hasOpenaiApiKey) {
db.run("ALTER TABLE users ADD COLUMN openai_api_key TEXT", (err) => {
if (err) {
console.error(
"Ошибка добавления колонки openai_api_key:",
err.message
);
} else {
console.log("Колонка openai_api_key добавлена в таблицу users");
}
});
}
if (!hasOpenaiBaseUrl) {
db.run("ALTER TABLE users ADD COLUMN openai_base_url TEXT", (err) => {
if (err) {
console.error(
"Ошибка добавления колонки openai_base_url:",
err.message
);
} else {
console.log("Колонка openai_base_url добавлена в таблицу users");
}
});
}
if (!hasOpenaiModel) {
db.run("ALTER TABLE users ADD COLUMN openai_model TEXT", (err) => {
if (err) {
console.error("Ошибка добавления колонки openai_model:", err.message);
} else {
console.log("Колонка openai_model добавлена в таблицу users");
}
});
}
});
// Проверяем существование колонок в таблице notes и добавляем их если нужно
@ -1537,6 +1584,115 @@ app.get("/api/notes/archived", requireAuth, (req, res) => {
});
});
// API для окончательного удаления всех архивных заметок пользователя
app.delete("/api/notes/archived/all", requireAuth, async (req, res) => {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: "Пароль обязателен" });
}
try {
// Получаем хеш пароля пользователя
const userSql = "SELECT password FROM users WHERE id = ?";
db.get(userSql, [req.session.userId], async (err, user) => {
if (err) {
console.error("Ошибка получения пользователя:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
// Проверяем пароль
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: "Неверный пароль" });
}
// Получаем все архивные заметки пользователя
const getNotesSql =
"SELECT id FROM notes WHERE user_id = ? AND is_archived = 1";
db.all(getNotesSql, [req.session.userId], (err, notes) => {
if (err) {
console.error("Ошибка получения архивных заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (notes.length === 0) {
return res.json({
success: true,
message: "Архив уже пуст",
deletedCount: 0,
});
}
// Удаляем изображения для всех заметок
const noteIds = notes.map((note) => note.id);
const placeholders = noteIds.map(() => "?").join(",");
const getImagesSql = `SELECT file_path FROM note_images WHERE note_id IN (${placeholders})`;
db.all(getImagesSql, noteIds, (err, images) => {
if (err) {
console.error("Ошибка получения изображений:", err.message);
} else {
// Удаляем файлы изображений
images.forEach((image) => {
const imagePath = path.join(__dirname, "public", image.file_path);
if (fs.existsSync(imagePath)) {
try {
fs.unlinkSync(imagePath);
} catch (err) {
console.error("Ошибка удаления файла изображения:", err);
}
}
});
}
// Удаляем записи об изображениях
const deleteImagesSql = `DELETE FROM note_images WHERE note_id IN (${placeholders})`;
db.run(deleteImagesSql, noteIds, (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
// Удаляем сами заметки
const deleteNotesSql =
"DELETE FROM notes WHERE user_id = ? AND is_archived = 1";
db.run(deleteNotesSql, [req.session.userId], function (err) {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
const deletedCount = this.changes;
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Удалены все архивные заметки (${deletedCount} шт.)`,
clientIP
);
res.json({
success: true,
message: `Удалено ${deletedCount} архивных заметок`,
deletedCount,
});
});
});
});
});
});
} catch (error) {
console.error("Ошибка при проверке пароля:", error);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// API для окончательного удаления архивной заметки
app.delete("/api/notes/archived/:id", requireAuth, (req, res) => {
const { id } = req.params;
@ -1606,6 +1762,114 @@ app.delete("/api/notes/archived/:id", requireAuth, (req, res) => {
});
});
// API для окончательного удаления всех архивных заметок пользователя
app.delete("/api/notes/archived/all", requireAuth, async (req, res) => {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: "Пароль обязателен" });
}
try {
// Получаем хеш пароля пользователя
const userSql = "SELECT password FROM users WHERE id = ?";
db.get(userSql, [req.session.userId], async (err, user) => {
if (err) {
console.error("Ошибка получения пользователя:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
// Проверяем пароль
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: "Неверный пароль" });
}
// Получаем все архивные заметки пользователя
const getNotesSql =
"SELECT id FROM notes WHERE user_id = ? AND is_archived = 1";
db.all(getNotesSql, [req.session.userId], (err, notes) => {
if (err) {
console.error("Ошибка получения архивных заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (notes.length === 0) {
return res.json({
success: true,
message: "Архив уже пуст",
deletedCount: 0,
});
}
// Удаляем изображения для всех заметок
const noteIds = notes.map((note) => note.id);
const placeholders = noteIds.map(() => "?").join(",");
const getImagesSql = `SELECT file_path FROM note_images WHERE note_id IN (${placeholders})`;
db.all(getImagesSql, noteIds, (err, images) => {
if (err) {
console.error("Ошибка получения изображений:", err.message);
} else {
// Удаляем файлы изображений
images.forEach((image) => {
const imagePath = path.join(__dirname, "public", image.file_path);
if (fs.existsSync(imagePath)) {
try {
fs.unlinkSync(imagePath);
} catch (err) {
console.error("Ошибка удаления файла изображения:", err);
}
}
});
}
// Удаляем записи об изображениях
const deleteImagesSql = `DELETE FROM note_images WHERE note_id IN (${placeholders})`;
db.run(deleteImagesSql, noteIds, (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
// Удаляем сами заметки
const deleteNotesSql = `DELETE FROM notes WHERE user_id = ? AND is_archived = 1`;
db.run(deleteNotesSql, [req.session.userId], function (err) {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
const deletedCount = this.changes;
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Удалены все архивные заметки (${deletedCount} шт.)`,
clientIP
);
res.json({
success: true,
message: `Удалено ${deletedCount} архивных заметок`,
deletedCount,
});
});
});
});
});
});
} catch (error) {
console.error("Ошибка при проверке пароля:", error);
res.status(500).json({ error: "Ошибка сервера" });
}
});
// API для получения логов пользователя
app.get("/api/logs", requireAuth, (req, res) => {
const { action_type, limit = 100, offset = 0 } = req.query;
@ -1674,6 +1938,197 @@ app.get("/settings", requireAuth, (req, res) => {
});
});
// API для получения AI настроек
app.get("/api/user/ai-settings", requireAuth, (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Не аутентифицирован" });
}
const sql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!settings) {
return res.status(404).json({ error: "Настройки не найдены" });
}
res.json(settings);
});
});
// API для сохранения AI настроек
app.put("/api/user/ai-settings", requireAuth, (req, res) => {
const { openai_api_key, openai_base_url, openai_model } = req.body;
const userId = req.session.userId;
if (!openai_api_key || !openai_base_url || !openai_model) {
return res.status(400).json({ error: "Все поля обязательны" });
}
const updateSql =
"UPDATE users SET openai_api_key = ?, openai_base_url = ?, openai_model = ? WHERE id = ?";
db.run(
updateSql,
[openai_api_key, openai_base_url, openai_model, userId],
function (err) {
if (err) {
console.error("Ошибка сохранения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
// Логируем обновление AI настроек
const clientIP = getClientIP(req);
logAction(userId, "profile_update", "Обновлены AI настройки", clientIP);
res.json({ success: true, message: "AI настройки успешно сохранены" });
}
);
});
// API для улучшения текста через AI
app.post("/api/ai/improve", requireAuth, async (req, res) => {
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: "Текст обязателен" });
}
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (
!settings ||
!settings.openai_api_key ||
!settings.openai_base_url ||
!settings.openai_model
) {
return res
.status(400)
.json({ error: "Настройте AI настройки в параметрах" });
}
try {
// Парсим URL
const url = new URL(settings.openai_base_url);
const isHttps = url.protocol === "https:";
const hostname = url.hostname;
const port = url.port || (isHttps ? 443 : 80);
// Формируем путь, избегая дублирования
let path = url.pathname || "";
if (!path.endsWith("/chat/completions")) {
path = path.endsWith("/")
? path + "chat/completions"
: path + "/chat/completions";
}
// Подготавливаем данные для запроса
const requestData = JSON.stringify({
model: settings.openai_model,
messages: [
{
role: "system",
content:
"Ты универсальный помощник для работы с текстом. Твоя задача:\n1. Если пользователь просит что-то придумать или сгенерировать (например, поздравление, письмо, пост) - создай качественный текст на заданную тему\n2. Если пользователь предоставил готовый текст - улучши его: исправь ошибки, улучши стиль и грамотность, сделай текст более понятным и выразительным, сохрани основную мысль\n3. Если пользователь просит создать список, используй markdown формат:\n - Для маркированных списков используй: - или *\n - Для нумерованных: 1. 2. 3.\n - Для заголовков: # ## ###\n4. Используй markdown форматирование (жирный **текст**, курсив *текст*, списки, заголовки) когда это уместно\n5. Обращай внимание на контекст и намерение пользователя\nВерни только готовый текст без дополнительных пояснений.",
},
{
role: "user",
content: text,
},
],
temperature: 0.5,
max_tokens: 4000,
});
// Выполняем HTTP запрос
const improvedText = await new Promise((resolve, reject) => {
const options = {
hostname: hostname,
port: port,
path: path,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.openai_api_key}`,
"Content-Length": Buffer.byteLength(requestData),
},
};
const client = isHttps ? https : http;
const req = client.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const responseData = JSON.parse(data);
const improvedText =
responseData.choices[0]?.message?.content || text;
resolve(improvedText);
} catch (err) {
console.error("Ошибка парсинга ответа:", err);
reject(new Error("Ошибка обработки ответа от AI"));
}
} else {
console.error("Ошибка OpenAI API:", res.statusCode, data);
reject(
new Error(
`Ошибка OpenAI API: ${res.statusCode} - ${data.substring(
0,
100
)}`
)
);
}
});
});
req.on("error", (error) => {
console.error("Ошибка запроса к OpenAI:", error);
reject(new Error("Ошибка подключения к OpenAI API"));
});
req.write(requestData);
req.end();
});
// Логируем использование AI
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"ai_improve",
"Улучшен текст через AI",
clientIP
);
res.json({ success: true, improvedText });
} catch (error) {
console.error("Ошибка вызова OpenAI API:", error);
return res
.status(500)
.json({ error: error.message || "Ошибка подключения к OpenAI API" });
}
});
} catch (error) {
console.error("Ошибка улучшения текста:", error);
res.status(500).json({ error: "Ошибка улучшения текста" });
}
});
// Выход
app.post("/logout", (req, res) => {
const userId = req.session.userId;