diff --git a/public/app.js b/public/app.js index 9253f34..451b27d 100644 --- a/public/app.js +++ b/public/app.js @@ -6,8 +6,10 @@ const notesList = document.getElementById("notes-container"); // Получаем кнопки markdown const boldBtn = document.getElementById("boldBtn"); const italicBtn = document.getElementById("italicBtn"); +const strikethroughBtn = document.getElementById("strikethroughBtn"); const colorBtn = document.getElementById("colorBtn"); const headerBtn = document.getElementById("headerBtn"); +const headerDropdown = document.getElementById("headerDropdown"); const listBtn = document.getElementById("listBtn"); const numberedListBtn = document.getElementById("numberedListBtn"); const quoteBtn = document.getElementById("quoteBtn"); @@ -16,6 +18,9 @@ const linkBtn = document.getElementById("linkBtn"); const checkboxBtn = document.getElementById("checkboxBtn"); const imageBtn = document.getElementById("imageBtn"); +// Кнопка настроек +const settingsBtn = document.getElementById("settings-btn"); + // Элементы для загрузки изображений const imageInput = document.getElementById("imageInput"); const imagePreviewContainer = document.getElementById("imagePreviewContainer"); @@ -698,12 +703,34 @@ italicBtn.addEventListener("click", function () { insertMarkdown("*"); }); +strikethroughBtn.addEventListener("click", function () { + insertMarkdown("~~"); +}); + colorBtn.addEventListener("click", function () { insertColorTag(); }); -headerBtn.addEventListener("click", function () { - insertMarkdown("# "); +// Обработчик кнопки заголовка - открываем выпадающее меню +headerBtn.addEventListener("click", function (event) { + event.stopPropagation(); + headerDropdown.classList.toggle("show"); +}); + +// Обработчики для пунктов выпадающего меню +headerDropdown.querySelectorAll("button").forEach((btn) => { + btn.addEventListener("click", function (event) { + event.stopPropagation(); + const level = this.dataset.level; + const headerTag = "#".repeat(parseInt(level)) + " "; + insertMarkdown(headerTag); + headerDropdown.classList.remove("show"); + }); +}); + +// Закрытие выпадающего меню при клике вне его +document.addEventListener("click", function () { + headerDropdown.classList.remove("show"); }); listBtn.addEventListener("click", function () { @@ -1129,7 +1156,7 @@ function highlightSearchText(content, query) { return content.replace(regex, '$1'); } -// Настройка marked.js для поддержки чекбоксов +// Настройка marked.js для поддержки чекбоксов и strikethrough const renderer = new marked.Renderer(); // Переопределяем рендеринг списков, чтобы чекбоксы были кликабельными (без disabled) @@ -1147,7 +1174,7 @@ renderer.listitem = function (text, task, checked) { }; marked.setOptions({ - gfm: true, // GitHub Flavored Markdown + gfm: true, // GitHub Flavored Markdown (включает strikethrough) breaks: true, renderer: renderer, html: true, // Разрешить HTML теги @@ -1240,16 +1267,26 @@ async function renderNotes(notes) { dateDisplay = `${note.date} ${note.time}`; } + // Определяем класс для закрепленной заметки + const pinnedClass = note.is_pinned ? " note-pinned" : ""; + const pinIndicator = note.is_pinned + ? 'Закреплено' + : ""; + const noteHtml = ` -
+
- ${dateDisplay} -
Ред.
-
Удал.
+ ${dateDisplay}${pinIndicator} +
+
+ +
+
+ +
+
Ред.
+
Удал.
+
{ + btn.addEventListener("click", async function (event) { + const noteId = event.target.closest("#pinBtn").dataset.id; + try { + const response = await fetch(`/api/notes/${noteId}/pin`, { + method: "PUT", + }); + + if (!response.ok) { + throw new Error("Ошибка изменения закрепления"); + } + + const result = await response.json(); + + // Перезагружаем заметки + await loadNotes(true); + } catch (error) { + console.error("Ошибка:", error); + alert("Ошибка изменения закрепления"); + } + }); + }); + + // Обработчик архивирования + document.querySelectorAll("#archiveBtn").forEach((btn) => { + btn.addEventListener("click", async function (event) { + const noteId = event.target.closest("#archiveBtn").dataset.id; + if (confirm("Архивировать эту заметку? Её можно будет восстановить из настроек.")) { + try { + const response = await fetch(`/api/notes/${noteId}/archive`, { + method: "PUT", + }); + + if (!response.ok) { + throw new Error("Ошибка архивирования заметки"); + } + + // Перезагружаем заметки + await loadNotes(true); + } catch (error) { + console.error("Ошибка:", error); + alert("Ошибка архивирования заметки"); + } + } + }); + }); + // Обработчик удаления document.querySelectorAll("#deleteBtn").forEach((btn) => { btn.addEventListener("click", async function (event) { @@ -2257,6 +2342,13 @@ document.addEventListener("DOMContentLoaded", function () { // Добавляем обработчик для кнопки выхода setupLogoutHandler(); + + // Обработчик для кнопки настроек + if (settingsBtn) { + settingsBtn.addEventListener("click", function () { + window.location.href = "/settings"; + }); + } }); // Функция для настройки обработчика выхода @@ -2314,25 +2406,39 @@ async function loadUserInfo() { const response = await fetch("/api/user"); if (response.ok) { const user = await response.json(); - const usernameDisplay = document.getElementById("username-display"); const userAvatar = document.getElementById("user-avatar"); - const userAvatarContainer = document.getElementById( - "user-avatar-container" - ); + const userAvatarContainer = document.getElementById("user-avatar-container"); + const userAvatarPlaceholder = document.getElementById("user-avatar-placeholder"); - if (usernameDisplay) { - usernameDisplay.innerHTML = ` ${user.username}`; + // Показываем аватарку или плейсхолдер + if (user.avatar) { + if (userAvatar && userAvatarContainer) { + userAvatar.src = user.avatar; + userAvatarContainer.style.display = "block"; + if (userAvatarPlaceholder) { + userAvatarPlaceholder.style.display = "none"; + } + } + } else { + if (userAvatarPlaceholder) { + userAvatarPlaceholder.style.display = "flex"; + } + if (userAvatarContainer) { + userAvatarContainer.style.display = "none"; + } + } - // Делаем ник кликабельным для перехода в личный кабинет - usernameDisplay.style.cursor = "pointer"; - usernameDisplay.addEventListener("click", function () { + // Делаем аватарку и плейсхолдер кликабельными для перехода в профиль + if (userAvatarContainer) { + userAvatarContainer.style.cursor = "pointer"; + userAvatarContainer.addEventListener("click", function () { window.location.href = "/profile"; }); } - - // Аватарка скрыта на странице заметок - if (userAvatarContainer) { - userAvatarContainer.style.display = "none"; + if (userAvatarPlaceholder) { + userAvatarPlaceholder.addEventListener("click", function () { + window.location.href = "/profile"; + }); } // Применяем цветовой акцент пользователя (только если отличается от текущего) diff --git a/public/notes.html b/public/notes.html index e7e7c95..3b55d3e 100644 --- a/public/notes.html +++ b/public/notes.html @@ -222,33 +222,41 @@ style="display: none" >
-
- - -
- -
+
@@ -258,12 +266,26 @@ + - +
+ +
+ + + + + + +
+
diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..b7e0db2 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,157 @@ + + + + + + Настройки - NoteJS + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Настройки + +
+ + +
+ + +
+ + +
+ +
+

Архивные заметки

+

+ Архивированные заметки можно восстановить или удалить окончательно +

+
+ +
+
+ + +
+

История действий

+ + +
+ + +
+ + +
+ + + + + + + + + + + + +
Дата и времяДействиеДеталиIP-адрес
+
+ + +
+
+
+ + + + +
+ × + +
+ + + + + + + diff --git a/public/settings.js b/public/settings.js new file mode 100644 index 0000000..f5e40e0 --- /dev/null +++ b/public/settings.js @@ -0,0 +1,359 @@ +// Переменные для пагинации логов +let logsOffset = 0; +const logsLimit = 50; +let hasMoreLogs = true; + +// Проверка аутентификации +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); + } + } + } catch (error) { + console.error("Ошибка загрузки информации о пользователе:", error); + } +} + +// Переключение табов +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); + } + }); + }); +} + +// Загрузка архивных заметок +async function loadArchivedNotes() { + const container = document.getElementById("archived-notes-container"); + container.innerHTML = '

Загрузка...

'; + + try { + const response = await fetch("/api/notes/archived"); + if (!response.ok) { + throw new Error("Ошибка загрузки архивных заметок"); + } + + const notes = await response.json(); + + if (notes.length === 0) { + container.innerHTML = + '

Архив пуст

'; + return; + } + + container.innerHTML = ""; + + notes.forEach((note) => { + const noteDiv = document.createElement("div"); + noteDiv.className = "archived-note-item"; + noteDiv.dataset.noteId = note.id; + + // Форматируем дату + const created = new Date(note.created_at.replace(" ", "T") + "Z"); + const dateStr = new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(created); + + // Преобразуем markdown в HTML для предпросмотра + const htmlContent = marked.parse(note.content); + const preview = htmlContent.substring(0, 200) + (htmlContent.length > 200 ? "..." : ""); + + // Изображения + let imagesHtml = ""; + if (note.images && note.images.length > 0) { + imagesHtml = `
${note.images.length} изображений
`; + } + + noteDiv.innerHTML = ` +
+ ${dateStr} +
+ + +
+
+
${preview}
+ ${imagesHtml} + `; + + container.appendChild(noteDiv); + }); + + // Добавляем обработчики событий + addArchivedNotesEventListeners(); + } catch (error) { + console.error("Ошибка загрузки архивных заметок:", error); + container.innerHTML = + '

Ошибка загрузки архивных заметок

'; + } +} + +// Добавление обработчиков для архивных заметок +function addArchivedNotesEventListeners() { + // Восстановление + document.querySelectorAll(".btn-restore").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const noteId = e.target.closest("button").dataset.id; + if (confirm("Восстановить эту заметку из архива?")) { + 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 = + '

Архив пуст

'; + } + + alert("Заметка восстановлена!"); + } catch (error) { + console.error("Ошибка:", error); + alert("Ошибка восстановления заметки"); + } + } + }); + }); + + // Окончательное удаление + document.querySelectorAll(".btn-delete-permanent").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const noteId = e.target.closest("button").dataset.id; + if ( + confirm( + "Удалить эту заметку НАВСЕГДА? Это действие нельзя отменить!" + ) + ) { + 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 = + '

Архив пуст

'; + } + + alert("Заметка удалена окончательно"); + } catch (error) { + console.error("Ошибка:", error); + alert("Ошибка удаления заметки"); + } + } + }); + }); +} + +// Загрузка логов +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 = 'Загрузка...'; + } + + 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 = + 'Логов пока нет'; + 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: "Обновление профиля", + }; + + const actionText = actionTypes[log.action_type] || log.action_type; + + row.innerHTML = ` + ${dateStr} + ${actionText} + ${log.details || "-"} + ${log.ip_address || "-"} + `; + + 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 = + 'Ошибка загрузки логов'; + } + } +} + +// Инициализация при загрузке страницы +document.addEventListener("DOMContentLoaded", async function () { + // Проверяем аутентификацию + const isAuth = await checkAuthentication(); + if (!isAuth) return; + + // Загружаем информацию о пользователе + await loadUserInfo(); + + // Инициализируем табы + initTabs(); + + // Загружаем архивные заметки по умолчанию + loadArchivedNotes(); + + // Обработчик фильтра логов + document.getElementById("logTypeFilter").addEventListener("change", () => { + loadLogs(true); + }); + + // Обработчик кнопки обновления логов + document.getElementById("refreshLogs").addEventListener("click", () => { + loadLogs(true); + }); + + // Обработчик кнопки "Загрузить еще" + document.getElementById("loadMoreLogsBtn").addEventListener("click", () => { + loadLogs(false); + }); +}); + + diff --git a/public/style.css b/public/style.css index cc52199..63f2ff7 100644 --- a/public/style.css +++ b/public/style.css @@ -247,6 +247,70 @@ header { font-weight: 500; } +/* Мини-аватарка в хедере */ +.user-avatar-mini { + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid var(--accent-color, #007bff); + flex-shrink: 0; +} + +.user-avatar-mini:hover { + transform: scale(1.05); + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2); +} + +.user-avatar-mini img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-avatar-placeholder-mini { + display: flex; + align-items: center; + justify-content: center; + background: #e0e0e0; + color: #999; + font-size: 24px; +} + +/* Иконка настроек */ +.settings-icon-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #ddd; + background: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + flex-shrink: 0; +} + +.settings-icon-btn:hover { + background: var(--accent-color, #007bff); + border-color: var(--accent-color, #007bff); + color: white; + transform: rotate(45deg); +} + +.settings-icon-btn .iconify { + font-size: 20px; + color: #666; + transition: color 0.3s ease; +} + +.settings-icon-btn:hover .iconify { + color: white; +} + .logout-btn { padding: 8px 16px; cursor: pointer; @@ -376,25 +440,67 @@ textarea:focus { .date { font-size: 11px; color: grey; - white-space: nowrap; display: flex; align-items: center; + justify-content: space-between; gap: 10px; - flex-wrap: nowrap; + flex-wrap: wrap; } .date .date-text { flex: 1; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.note-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } .notesHeaderBtn { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; cursor: pointer; color: black; font-weight: bold; white-space: nowrap; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; + font-size: 11px; +} + +.notesHeaderBtn:hover { + background: rgba(0, 0, 0, 0.05); +} + +#pinBtn:hover { + background: #fff3cd; + color: #856404; +} + +#archiveBtn:hover { + background: #e2e3e5; + color: #383d41; +} + +#editBtn:hover { + background: #d1ecf1; + color: #0c5460; +} + +#deleteBtn:hover { + background: #f8d7da; + color: #721c24; } .textNote { @@ -662,6 +768,56 @@ textarea:focus { background-color: #e0e0e0; } +/* Выпадающее меню заголовков */ +.header-dropdown { + position: relative; + display: inline-block; +} + +.header-dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + background: white; + border: 1px solid #ddd; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 100; + margin-top: 2px; + min-width: 80px; +} + +.header-dropdown-menu.show { + display: block; +} + +.header-dropdown-menu button { + display: block; + width: 100%; + padding: 8px 16px; + border: none; + background: none; + text-align: left; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #333; + transition: background 0.2s ease; +} + +.header-dropdown-menu button:hover { + background: #f0f0f0; +} + +.header-dropdown-menu button:first-child { + border-radius: 5px 5px 0 0; +} + +.header-dropdown-menu button:last-child { + border-radius: 0 0 5px 5px; +} + .footer { text-align: center; font-size: 12px; @@ -1710,3 +1866,315 @@ textarea:focus { border-radius: 5px; cursor: pointer; } + +/* Стили для страницы настроек */ +.settings-tabs { + display: flex; + gap: 10px; + margin: 20px 0; + border-bottom: 2px solid #e0e0e0; + flex-wrap: wrap; +} + +.settings-tab { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 16px; + color: #666; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.settings-tab:hover { + color: var(--accent-color, #007bff); + background: rgba(0, 123, 255, 0.05); +} + +.settings-tab.active { + color: var(--accent-color, #007bff); + border-bottom-color: var(--accent-color, #007bff); + font-weight: bold; +} + +.settings-content { + margin-top: 20px; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Архивные заметки */ +.archived-notes-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.archived-note-item { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 15px; + transition: all 0.3s ease; +} + +.archived-note-item:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.archived-note-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + flex-wrap: wrap; + gap: 10px; +} + +.archived-note-date { + font-size: 12px; + color: #999; +} + +.archived-note-actions { + display: flex; + gap: 8px; +} + +.btn-restore, +.btn-delete-permanent { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 5px; + background: white; + cursor: pointer; + font-size: 13px; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 4px; +} + +.btn-restore { + color: var(--accent-color, #007bff); + border-color: var(--accent-color, #007bff); +} + +.btn-restore:hover { + background: var(--accent-color, #007bff); + color: white; +} + +.btn-delete-permanent { + color: #dc3545; + border-color: #dc3545; +} + +.btn-delete-permanent:hover { + background: #dc3545; + color: white; +} + +.archived-note-content { + font-size: 14px; + color: #333; + line-height: 1.5; + max-height: 100px; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.archived-note-images { + margin-top: 10px; + font-size: 12px; + color: #666; + font-style: italic; +} + +/* История действий */ +.logs-filters { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.log-filter-select { + flex: 1; + min-width: 200px; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 5px; + font-size: 14px; +} + +.log-filter-select:focus { + outline: none; + border-color: var(--accent-color, #007bff); +} + +.logs-table-container { + overflow-x: auto; + margin-bottom: 20px; +} + +.logs-table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.logs-table thead { + background: #f8f9fa; +} + +.logs-table th { + padding: 12px; + text-align: left; + font-weight: bold; + color: #333; + border-bottom: 2px solid #dee2e6; + font-size: 14px; +} + +.logs-table td { + padding: 12px; + border-bottom: 1px solid #dee2e6; + font-size: 13px; + color: #666; +} + +.logs-table tbody tr:hover { + background: #f8f9fa; +} + +.log-action-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.log-action-login, +.log-action-register { + background: #d4edda; + color: #155724; +} + +.log-action-logout { + background: #f8d7da; + color: #721c24; +} + +.log-action-note_create { + background: #d1ecf1; + color: #0c5460; +} + +.log-action-note_update { + background: #fff3cd; + color: #856404; +} + +.log-action-note_delete, +.log-action-note_delete_permanent { + background: #f8d7da; + color: #721c24; +} + +.log-action-note_pin { + background: #e7e7ff; + color: #4a4aff; +} + +.log-action-note_archive { + background: #e2e3e5; + color: #383d41; +} + +.log-action-note_unarchive { + background: #d4edda; + color: #155724; +} + +.log-action-profile_update { + background: #fce4ec; + color: #c2185b; +} + +.load-more-container { + text-align: center; + margin: 20px 0; +} + +/* Стили для закрепленных заметок */ +.note-pinned { + border-left: 4px solid #ffc107; + background: #fffbf0; +} + +.pin-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + color: #ffc107; + font-size: 12px; + font-weight: bold; + margin-left: 10px; +} + +/* Адаптивность для настроек */ +@media (max-width: 768px) { + .settings-tabs { + flex-direction: column; + } + + .settings-tab { + width: 100%; + justify-content: center; + } + + .archived-note-header { + flex-direction: column; + align-items: flex-start; + } + + .archived-note-actions { + width: 100%; + justify-content: flex-start; + } + + .logs-table { + font-size: 12px; + } + + .logs-table th, + .logs-table td { + padding: 8px; + } + + .logs-filters { + flex-direction: column; + } + + .log-filter-select { + width: 100%; + } +} diff --git a/server.js b/server.js index 1f61a3e..1914f1c 100644 --- a/server.js +++ b/server.js @@ -258,8 +258,12 @@ function createIndexes() { "CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at)", "CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at)", "CREATE INDEX IF NOT EXISTS idx_notes_date ON notes(date)", + "CREATE INDEX IF NOT EXISTS idx_notes_is_pinned ON notes(is_pinned)", + "CREATE INDEX IF NOT EXISTS idx_notes_is_archived ON notes(is_archived)", "CREATE INDEX IF NOT EXISTS idx_note_images_note_id ON note_images(note_id)", "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)", + "CREATE INDEX IF NOT EXISTS idx_action_logs_user_id ON action_logs(user_id)", + "CREATE INDEX IF NOT EXISTS idx_action_logs_created_at ON action_logs(created_at)", ]; indexes.forEach((indexSql, i) => { @@ -273,8 +277,54 @@ function createIndexes() { }); } +// Функция для логирования действий пользователя +function logAction(userId, actionType, details, ipAddress) { + const sql = ` + INSERT INTO action_logs (user_id, action_type, details, ip_address) + VALUES (?, ?, ?, ?) + `; + db.run(sql, [userId, actionType, details, ipAddress], (err) => { + if (err) { + console.error("Ошибка логирования действия:", err.message); + } + }); +} + +// Функция для получения IP-адреса клиента +function getClientIP(req) { + return ( + req.headers["x-forwarded-for"]?.split(",")[0].trim() || + req.headers["x-real-ip"] || + req.connection?.remoteAddress || + req.socket?.remoteAddress || + req.connection?.socket?.remoteAddress || + "unknown" + ); +} + // Миграции базы данных function runMigrations() { + // Создаем таблицу логов действий, если её нет + const createLogsTable = ` + CREATE TABLE IF NOT EXISTS action_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, + details TEXT, + ip_address TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `; + + db.run(createLogsTable, (err) => { + if (err) { + console.error("Ошибка создания таблицы логов:", err.message); + } else { + console.log("Таблица action_logs готова"); + } + }); + // Проверяем существование колонки accent_color и добавляем её если нужно db.all("PRAGMA table_info(users)", (err, columns) => { if (err) { @@ -300,7 +350,7 @@ function runMigrations() { } }); - // Проверяем существование колонки updated_at в таблице notes и добавляем её если нужно + // Проверяем существование колонок в таблице notes и добавляем их если нужно db.all("PRAGMA table_info(notes)", (err, columns) => { if (err) { console.error("Ошибка проверки структуры таблицы notes:", err.message); @@ -308,13 +358,16 @@ function runMigrations() { } const hasUpdatedAt = columns.some((col) => col.name === "updated_at"); + const hasPinned = columns.some((col) => col.name === "is_pinned"); + const hasArchived = columns.some((col) => col.name === "is_archived"); + + // Добавляем updated_at если нужно if (!hasUpdatedAt) { db.run("ALTER TABLE notes ADD COLUMN updated_at DATETIME", (err) => { if (err) { console.error("Ошибка добавления колонки updated_at:", err.message); } else { console.log("Колонка updated_at добавлена в таблицу notes"); - // Устанавливаем updated_at = created_at для существующих записей db.run( "UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL", (err) => { @@ -323,20 +376,50 @@ function runMigrations() { "Ошибка обновления updated_at для существующих записей:", err.message ); - } else { - console.log( - "Колонка updated_at обновлена для существующих записей" - ); - // Создаем индексы после добавления колонки - createIndexes(); } } ); } }); - } else { - // Если колонка уже существует, просто создаем индексы + } + + // Добавляем is_pinned если нужно + if (!hasPinned) { + db.run( + "ALTER TABLE notes ADD COLUMN is_pinned INTEGER DEFAULT 0", + (err) => { + if (err) { + console.error("Ошибка добавления колонки is_pinned:", err.message); + } else { + console.log("Колонка is_pinned добавлена в таблицу notes"); + } + } + ); + } + + // Добавляем is_archived если нужно + if (!hasArchived) { + db.run( + "ALTER TABLE notes ADD COLUMN is_archived INTEGER DEFAULT 0", + (err) => { + if (err) { + console.error( + "Ошибка добавления колонки is_archived:", + err.message + ); + } else { + console.log("Колонка is_archived добавлена в таблицу notes"); + } + } + ); + } + + // Создаем индексы после всех изменений + if (hasUpdatedAt && hasPinned && hasArchived) { createIndexes(); + } else { + // Задержка для завершения миграций + setTimeout(createIndexes, 1000); } }); } @@ -412,6 +495,11 @@ app.post("/api/register", async (req, res) => { req.session.userId = this.lastID; req.session.username = username; req.session.authenticated = true; + + // Логируем регистрацию + const clientIP = getClientIP(req); + logAction(this.lastID, "register", `Регистрация нового пользователя`, clientIP); + res.json({ success: true, message: "Регистрация успешна" }); }); } catch (err) { @@ -450,6 +538,11 @@ app.post("/api/login", async (req, res) => { req.session.userId = user.id; req.session.username = user.username; req.session.authenticated = true; + + // Логируем вход + const clientIP = getClientIP(req); + logAction(user.id, "login", `Вход в систему`, clientIP); + res.json({ success: true, message: "Вход успешен" }); } catch (err) { console.error("Ошибка при сравнении паролей:", err); @@ -609,7 +702,7 @@ app.get("/api/notes/search", requireAuth, (req, res) => { }); }); -// API для получения всех заметок с изображениями +// API для получения всех заметок с изображениями (исключая архивные) app.get("/api/notes", requireAuth, (req, res) => { const sql = ` SELECT @@ -630,9 +723,9 @@ app.get("/api/notes", requireAuth, (req, res) => { END as images FROM notes n LEFT JOIN note_images ni ON n.id = ni.note_id - WHERE n.user_id = ? + WHERE n.user_id = ? AND n.is_archived = 0 GROUP BY n.id - ORDER BY n.created_at DESC + ORDER BY n.is_pinned DESC, n.created_at DESC `; db.all(sql, [req.session.userId], (err, rows) => { @@ -668,7 +761,13 @@ app.post("/api/notes", requireAuth, (req, res) => { console.error("Ошибка создания заметки:", err.message); return res.status(500).json({ error: "Ошибка сервера" }); } - res.json({ id: this.lastID, content, date, time }); + + // Логируем создание заметки + const clientIP = getClientIP(req); + const noteId = this.lastID; + logAction(req.session.userId, "note_create", `Создана заметка #${noteId}`, clientIP); + + res.json({ id: noteId, content, date, time }); }); }); @@ -709,6 +808,11 @@ app.put("/api/notes/:id", requireAuth, (req, res) => { if (this.changes === 0) { return res.status(404).json({ error: "Заметка не найдена" }); } + + // Логируем обновление заметки + const clientIP = getClientIP(req); + logAction(req.session.userId, "note_update", `Обновлена заметка #${id}`, clientIP); + res.json({ id, content, date: row.date, time: row.time }); }); }); @@ -767,6 +871,11 @@ app.delete("/api/notes/:id", requireAuth, (req, res) => { if (this.changes === 0) { return res.status(404).json({ error: "Заметка не найдена" }); } + + // Логируем удаление заметки + const clientIP = getClientIP(req); + logAction(req.session.userId, "note_delete", `Удалена заметка #${id}`, clientIP); + res.json({ message: "Заметка удалена" }); }); }); @@ -1091,6 +1200,16 @@ app.put("/api/user/profile", requireAuth, async (req, res) => { req.session.username = username; } + // Логируем обновление профиля + const clientIP = getClientIP(req); + const changes = []; + if (username && username !== user.username) changes.push("логин"); + if (email !== undefined) changes.push("email"); + if (accent_color !== undefined) changes.push("цвет темы"); + if (newPassword) changes.push("пароль"); + const details = `Обновлен профиль: ${changes.join(", ")}`; + logAction(userId, "profile_update", details, clientIP); + res.json({ success: true, message: "Профиль успешно обновлен" }); }); }); @@ -1177,8 +1296,303 @@ app.delete("/api/user/avatar", requireAuth, (req, res) => { }); }); +// API для закрепления заметки +app.put("/api/notes/:id/pin", requireAuth, (req, res) => { + const { id } = req.params; + + // Проверяем, что заметка принадлежит текущему пользователю + const checkSql = "SELECT user_id, is_pinned FROM notes WHERE id = ?"; + db.get(checkSql, [id], (err, row) => { + if (err) { + console.error("Ошибка проверки доступа:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!row) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (row.user_id !== req.session.userId) { + return res.status(403).json({ error: "Нет доступа к этой заметке" }); + } + + const newPinState = row.is_pinned ? 0 : 1; + const updateSql = "UPDATE notes SET is_pinned = ? WHERE id = ?"; + + db.run(updateSql, [newPinState, id], function (err) { + if (err) { + console.error("Ошибка изменения закрепления:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Логируем действие + const clientIP = getClientIP(req); + const action = newPinState ? "закреплена" : "откреплена"; + logAction(req.session.userId, "note_pin", `Заметка #${id} ${action}`, clientIP); + + res.json({ success: true, is_pinned: newPinState }); + }); + }); +}); + +// API для архивирования заметки +app.put("/api/notes/:id/archive", requireAuth, (req, res) => { + const { id } = req.params; + + // Проверяем, что заметка принадлежит текущему пользователю + const checkSql = "SELECT user_id FROM notes WHERE id = ?"; + db.get(checkSql, [id], (err, row) => { + if (err) { + console.error("Ошибка проверки доступа:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!row) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (row.user_id !== req.session.userId) { + return res.status(403).json({ error: "Нет доступа к этой заметке" }); + } + + const updateSql = "UPDATE notes SET is_archived = 1, is_pinned = 0 WHERE id = ?"; + + db.run(updateSql, [id], function (err) { + if (err) { + console.error("Ошибка архивирования:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Логируем действие + const clientIP = getClientIP(req); + logAction(req.session.userId, "note_archive", `Заметка #${id} архивирована`, clientIP); + + res.json({ success: true, message: "Заметка архивирована" }); + }); + }); +}); + +// API для восстановления заметки из архива +app.put("/api/notes/:id/unarchive", requireAuth, (req, res) => { + const { id } = req.params; + + // Проверяем, что заметка принадлежит текущему пользователю + const checkSql = "SELECT user_id FROM notes WHERE id = ?"; + db.get(checkSql, [id], (err, row) => { + if (err) { + console.error("Ошибка проверки доступа:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!row) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (row.user_id !== req.session.userId) { + return res.status(403).json({ error: "Нет доступа к этой заметке" }); + } + + const updateSql = "UPDATE notes SET is_archived = 0 WHERE id = ?"; + + db.run(updateSql, [id], function (err) { + if (err) { + console.error("Ошибка восстановления:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Логируем действие + const clientIP = getClientIP(req); + logAction(req.session.userId, "note_unarchive", `Заметка #${id} восстановлена из архива`, clientIP); + + res.json({ success: true, message: "Заметка восстановлена" }); + }); + }); +}); + +// API для получения архивных заметок +app.get("/api/notes/archived", requireAuth, (req, res) => { + const sql = ` + SELECT + n.*, + CASE + WHEN COUNT(ni.id) = 0 THEN '[]' + ELSE json_group_array( + json_object( + 'id', ni.id, + 'filename', ni.filename, + 'original_name', ni.original_name, + 'file_path', ni.file_path, + 'file_size', ni.file_size, + 'mime_type', ni.mime_type, + 'created_at', ni.created_at + ) + ) + END as images + FROM notes n + LEFT JOIN note_images ni ON n.id = ni.note_id + WHERE n.user_id = ? AND n.is_archived = 1 + GROUP BY n.id + ORDER BY n.created_at DESC + `; + + db.all(sql, [req.session.userId], (err, rows) => { + if (err) { + console.error("Ошибка получения архивных заметок:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Парсим JSON строки изображений + const notesWithImages = rows.map((row) => ({ + ...row, + images: row.images === "[]" ? [] : JSON.parse(row.images), + })); + + res.json(notesWithImages); + }); +}); + +// API для окончательного удаления архивной заметки +app.delete("/api/notes/archived/:id", requireAuth, (req, res) => { + const { id } = req.params; + + // Проверяем, что заметка принадлежит текущему пользователю и архивирована + const checkSql = "SELECT user_id, is_archived FROM notes WHERE id = ?"; + db.get(checkSql, [id], (err, row) => { + if (err) { + console.error("Ошибка проверки доступа:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!row) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (row.user_id !== req.session.userId) { + return res.status(403).json({ error: "Нет доступа к этой заметке" }); + } + + if (!row.is_archived) { + return res.status(400).json({ error: "Заметка не архивирована" }); + } + + // Удаляем изображения заметки + const getImagesSql = "SELECT file_path FROM note_images WHERE note_id = ?"; + db.all(getImagesSql, [id], (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)) { + fs.unlinkSync(imagePath); + } + }); + } + + // Удаляем записи об изображениях + const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?"; + db.run(deleteImagesSql, [id], (err) => { + if (err) { + console.error("Ошибка удаления изображений:", err.message); + } + }); + + // Удаляем саму заметку + const deleteSql = "DELETE FROM notes WHERE id = ?"; + db.run(deleteSql, id, function (err) { + if (err) { + console.error("Ошибка удаления заметки:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Логируем действие + const clientIP = getClientIP(req); + logAction(req.session.userId, "note_delete_permanent", `Заметка #${id} окончательно удалена из архива`, clientIP); + + res.json({ success: true, message: "Заметка удалена окончательно" }); + }); + }); + }); +}); + +// API для получения логов пользователя +app.get("/api/logs", requireAuth, (req, res) => { + const { action_type, limit = 100, offset = 0 } = req.query; + + let sql = ` + SELECT id, action_type, details, ip_address, created_at + FROM action_logs + WHERE user_id = ? + `; + const params = [req.session.userId]; + + // Фильтр по типу действия + if (action_type) { + sql += " AND action_type = ?"; + params.push(action_type); + } + + sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"; + params.push(parseInt(limit), parseInt(offset)); + + db.all(sql, params, (err, rows) => { + if (err) { + console.error("Ошибка получения логов:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + res.json(rows); + }); +}); + +// Страница настроек +app.get("/settings", requireAuth, (req, res) => { + // Получаем цвет пользователя для предотвращения FOUC + const sql = "SELECT accent_color FROM users WHERE id = ?"; + db.get(sql, [req.session.userId], (err, user) => { + if (err) { + console.error("Ошибка получения цвета пользователя:", err.message); + return res.sendFile(path.join(__dirname, "public", "settings.html")); + } + + const accentColor = user?.accent_color || "#007bff"; + + // Читаем HTML файл + fs.readFile( + path.join(__dirname, "public", "settings.html"), + "utf8", + (err, html) => { + if (err) { + console.error("Ошибка чтения файла settings.html:", err.message); + return res.sendFile(path.join(__dirname, "public", "settings.html")); + } + + // Вставляем inline CSS с правильным цветом + const inlineCSS = ``; + const modifiedHtml = html.replace( + //i, + `\n ${inlineCSS}` + ); + + res.send(modifiedHtml); + } + ); + }); +}); + // Выход app.post("/logout", (req, res) => { + const userId = req.session.userId; + const clientIP = getClientIP(req); + + // Логируем выход + if (userId) { + logAction(userId, "logout", "Выход из системы", clientIP); + } + req.session.destroy((err) => { if (err) { return res.status(500).json({ error: "Ошибка выхода" });