Добавлены функции для управления заметками и логирования действий пользователей

- Реализованы функции для закрепления и архивирования заметок, а также их восстановления.
- Добавлены новые индексы в базу данных для улучшения производительности запросов.
- Внедрено логирование действий пользователей, включая регистрацию, вход, создание, обновление и удаление заметок.
- Обновлены интерфейсы для поддержки новых функций, включая кнопки для архивирования и закрепления заметок.
- Оптимизированы стили и добавлены новые элементы управления для улучшения пользовательского опыта.
This commit is contained in:
Fovway 2025-10-24 08:05:40 +07:00
parent 1172edf31e
commit f9ba1796dc
6 changed files with 1597 additions and 71 deletions

View File

@ -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, '<span class="search-highlight">$1</span>');
}
// Настройка 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
? '<span class="pin-indicator"><span class="iconify" data-icon="mdi:pin"></span>Закреплено</span>'
: "";
const noteHtml = `
<div id="note" class="container" data-note-id="${note.id}">
<div id="note" class="container${pinnedClass}" data-note-id="${note.id}">
<div class="date">
<span class="date-text">${dateDisplay}</span>
<div id="editBtn" class="notesHeaderBtn" data-id="${
note.id
}">Ред.</div>
<div id="deleteBtn" class="notesHeaderBtn" data-id="${
note.id
}">Удал.</div>
<span class="date-text">${dateDisplay}${pinIndicator}</span>
<div class="note-actions">
<div id="pinBtn" class="notesHeaderBtn" data-id="${note.id}" title="${note.is_pinned ? "Открепить" : "Закрепить"}">
<span class="iconify" data-icon="mdi:pin${note.is_pinned ? "-off" : ""}"></span>
</div>
<div id="archiveBtn" class="notesHeaderBtn" data-id="${note.id}" title="Архивировать">
<span class="iconify" data-icon="mdi:archive"></span>
</div>
<div id="editBtn" class="notesHeaderBtn" data-id="${note.id}">Ред.</div>
<div id="deleteBtn" class="notesHeaderBtn" data-id="${note.id}">Удал.</div>
</div>
</div>
<div class="textNote" data-original-content="${note.content.replace(
/"/g,
@ -1326,6 +1363,54 @@ function handleLongNotes() {
// Функция для добавления обработчиков событий к заметкам
function addNoteEventListeners() {
// Обработчик закрепления
document.querySelectorAll("#pinBtn").forEach((btn) => {
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 = `<span class="iconify" data-icon="mdi:account"></span> ${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";
});
}
// Применяем цветовой акцент пользователя (только если отличается от текущего)

View File

@ -225,24 +225,32 @@
<div class="user-info">
<div
id="user-avatar-container"
style="display: none; margin-right: 10px"
class="user-avatar-mini"
style="display: none"
title="Перейти в профиль"
>
<img
id="user-avatar"
src=""
alt="Аватар"
loading="lazy"
style="
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
vertical-align: middle;
border: 2px solid #007bff;
"
/>
</div>
<span id="username-display" class="username-clickable"></span>
<div
id="user-avatar-placeholder"
class="user-avatar-mini user-avatar-placeholder-mini"
style="display: none"
title="Перейти в профиль"
>
<span class="iconify" data-icon="mdi:account"></span>
</div>
<button
id="settings-btn"
class="settings-icon-btn"
title="Настройки"
>
<span class="iconify" data-icon="mdi:cog"></span>
</button>
<form action="/logout" method="POST" style="display: inline">
<button type="submit" class="logout-btn">
<span class="iconify" data-icon="mdi:logout"></span> Выйти
@ -258,12 +266,26 @@
<button class="btnMarkdown" id="italicBtn" title="Курсив">
<span class="iconify" data-icon="mdi:format-italic"></span>
</button>
<button class="btnMarkdown" id="strikethroughBtn" title="Перечеркнутый">
<span class="iconify" data-icon="mdi:format-strikethrough"></span>
</button>
<button class="btnMarkdown" id="colorBtn" title="Цвет текста">
<span class="iconify" data-icon="mdi:palette"></span>
</button>
<div class="header-dropdown">
<button class="btnMarkdown" id="headerBtn" title="Заголовок">
<span class="iconify" data-icon="mdi:format-header-1"></span>
<span class="iconify" data-icon="mdi:format-header-pound"></span>
<span class="iconify" data-icon="mdi:menu-down" style="font-size: 10px; margin-left: -2px;"></span>
</button>
<div class="header-dropdown-menu" id="headerDropdown">
<button data-level="1">H1</button>
<button data-level="2">H2</button>
<button data-level="3">H3</button>
<button data-level="4">H4</button>
<button data-level="5">H5</button>
<button data-level="6">H6</button>
</div>
</div>
<button class="btnMarkdown" id="listBtn" title="Список">
<span class="iconify" data-icon="mdi:format-list-bulleted"></span>
</button>

157
public/settings.html Normal file
View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Настройки - NoteJS</title>
<!-- PWA Meta Tags -->
<meta
name="description"
content="NoteJS - современная система заметок с поддержкой Markdown, изображений, тегов и календаря"
/>
<meta name="theme-color" content="#007bff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NoteJS" />
<meta name="msapplication-TileColor" content="#007bff" />
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/icons/icon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/icons/icon-16x16.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/icons/icon-192x192.png"
/>
<link rel="mask-icon" href="/icon.svg" color="#007bff" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Styles -->
<link rel="stylesheet" href="/style.css?v=5" />
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/iconify/2.0.0/iconify.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/11.1.0/marked.min.js"></script>
</head>
<body>
<div class="container">
<header class="notes-header">
<span
><span class="iconify" data-icon="mdi:cog"></span> Настройки</span
>
<div class="user-info">
<a href="/profile" class="back-btn">
<span class="iconify" data-icon="mdi:account"></span> Профиль
</a>
<a href="/notes" class="back-btn">
<span class="iconify" data-icon="mdi:note-text"></span> К заметкам
</a>
</div>
</header>
<!-- Табы навигации -->
<div class="settings-tabs">
<button class="settings-tab active" data-tab="archive">
<span class="iconify" data-icon="mdi:archive"></span> Архив заметок
</button>
<button class="settings-tab" data-tab="logs">
<span class="iconify" data-icon="mdi:history"></span> История действий
</button>
</div>
<!-- Контент табов -->
<div class="settings-content">
<!-- Архив заметок -->
<div class="tab-content active" id="archive-tab">
<h3>Архивные заметки</h3>
<p style="color: #666; font-size: 14px; margin-bottom: 20px">
Архивированные заметки можно восстановить или удалить окончательно
</p>
<div id="archived-notes-container" class="archived-notes-list">
<!-- Заметки будут загружены динамически -->
</div>
</div>
<!-- История действий -->
<div class="tab-content" id="logs-tab">
<h3>История действий</h3>
<!-- Фильтры -->
<div class="logs-filters">
<select id="logTypeFilter" class="log-filter-select">
<option value="">Все действия</option>
<option value="login">Вход</option>
<option value="logout">Выход</option>
<option value="register">Регистрация</option>
<option value="note_create">Создание заметки</option>
<option value="note_update">Редактирование заметки</option>
<option value="note_delete">Удаление заметки</option>
<option value="note_pin">Закрепление</option>
<option value="note_archive">Архивирование</option>
<option value="note_unarchive">Восстановление</option>
<option value="note_delete_permanent">Окончательное удаление</option>
<option value="profile_update">Обновление профиля</option>
</select>
<button id="refreshLogs" class="btnSave">
<span class="iconify" data-icon="mdi:refresh"></span> Обновить
</button>
</div>
<!-- Таблица логов -->
<div class="logs-table-container">
<table class="logs-table">
<thead>
<tr>
<th>Дата и время</th>
<th>Действие</th>
<th>Детали</th>
<th>IP-адрес</th>
</tr>
</thead>
<tbody id="logsTableBody">
<!-- Логи будут загружены динамически -->
</tbody>
</table>
</div>
<div id="logsLoadMore" class="load-more-container" style="display: none">
<button id="loadMoreLogsBtn" class="btnSave">Загрузить еще</button>
</div>
</div>
</div>
</div>
<div class="footer">
<p>Создатель: <span>Fovway</span></p>
</div>
<!-- Модальное окно для просмотра изображений -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span>
<img class="image-modal-content" id="modalImage" loading="lazy" />
</div>
<script src="/settings.js"></script>
<script src="/pwa.js"></script>
</body>
</html>

359
public/settings.js Normal file
View File

@ -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 = '<p style="text-align: center; color: #999;">Загрузка...</p>';
try {
const response = await fetch("/api/notes/archived");
if (!response.ok) {
throw new Error("Ошибка загрузки архивных заметок");
}
const notes = await response.json();
if (notes.length === 0) {
container.innerHTML =
'<p style="text-align: center; color: #999;">Архив пуст</p>';
return;
}
container.innerHTML = "";
notes.forEach((note) => {
const noteDiv = document.createElement("div");
noteDiv.className = "archived-note-item";
noteDiv.dataset.noteId = note.id;
// Форматируем дату
const created = new Date(note.created_at.replace(" ", "T") + "Z");
const dateStr = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(created);
// Преобразуем markdown в HTML для предпросмотра
const htmlContent = marked.parse(note.content);
const preview = htmlContent.substring(0, 200) + (htmlContent.length > 200 ? "..." : "");
// Изображения
let imagesHtml = "";
if (note.images && note.images.length > 0) {
imagesHtml = `<div class="archived-note-images">${note.images.length} изображений</div>`;
}
noteDiv.innerHTML = `
<div class="archived-note-header">
<span class="archived-note-date">${dateStr}</span>
<div class="archived-note-actions">
<button class="btn-restore" data-id="${note.id}" title="Восстановить">
<span class="iconify" data-icon="mdi:restore"></span> Восстановить
</button>
<button class="btn-delete-permanent" data-id="${note.id}" title="Удалить навсегда">
<span class="iconify" data-icon="mdi:delete-forever"></span> Удалить
</button>
</div>
</div>
<div class="archived-note-content">${preview}</div>
${imagesHtml}
`;
container.appendChild(noteDiv);
});
// Добавляем обработчики событий
addArchivedNotesEventListeners();
} catch (error) {
console.error("Ошибка загрузки архивных заметок:", error);
container.innerHTML =
'<p style="text-align: center; color: #dc3545;">Ошибка загрузки архивных заметок</p>';
}
}
// Добавление обработчиков для архивных заметок
function addArchivedNotesEventListeners() {
// Восстановление
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 =
'<p style="text-align: center; color: #999;">Архив пуст</p>';
}
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 =
'<p style="text-align: center; color: #999;">Архив пуст</p>';
}
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 = '<tr><td colspan="4" style="text-align: center;">Загрузка...</td></tr>';
}
try {
let url = `/api/logs?limit=${logsLimit}&offset=${logsOffset}`;
if (filterValue) {
url += `&action_type=${filterValue}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error("Ошибка загрузки логов");
}
const logs = await response.json();
if (reset) {
tbody.innerHTML = "";
}
if (logs.length === 0 && logsOffset === 0) {
tbody.innerHTML =
'<tr><td colspan="4" style="text-align: center; color: #999;">Логов пока нет</td></tr>';
loadMoreContainer.style.display = "none";
return;
}
if (logs.length < logsLimit) {
hasMoreLogs = false;
}
logs.forEach((log) => {
const row = document.createElement("tr");
// Форматируем дату
const created = new Date(log.created_at.replace(" ", "T") + "Z");
const dateStr = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(created);
// Форматируем тип действия
const actionTypes = {
login: "Вход",
logout: "Выход",
register: "Регистрация",
note_create: "Создание заметки",
note_update: "Редактирование",
note_delete: "Удаление",
note_pin: "Закрепление",
note_archive: "Архивирование",
note_unarchive: "Восстановление",
note_delete_permanent: "Окончательное удаление",
profile_update: "Обновление профиля",
};
const actionText = actionTypes[log.action_type] || log.action_type;
row.innerHTML = `
<td>${dateStr}</td>
<td><span class="log-action-badge log-action-${log.action_type}">${actionText}</span></td>
<td>${log.details || "-"}</td>
<td>${log.ip_address || "-"}</td>
`;
tbody.appendChild(row);
});
logsOffset += logs.length;
if (hasMoreLogs && logs.length > 0) {
loadMoreContainer.style.display = "block";
} else {
loadMoreContainer.style.display = "none";
}
} catch (error) {
console.error("Ошибка загрузки логов:", error);
if (reset) {
tbody.innerHTML =
'<tr><td colspan="4" style="text-align: center; color: #dc3545;">Ошибка загрузки логов</td></tr>';
}
}
}
// Инициализация при загрузке страницы
document.addEventListener("DOMContentLoaded", async function () {
// Проверяем аутентификацию
const isAuth = await checkAuthentication();
if (!isAuth) return;
// Загружаем информацию о пользователе
await loadUserInfo();
// Инициализируем табы
initTabs();
// Загружаем архивные заметки по умолчанию
loadArchivedNotes();
// Обработчик фильтра логов
document.getElementById("logTypeFilter").addEventListener("change", () => {
loadLogs(true);
});
// Обработчик кнопки обновления логов
document.getElementById("refreshLogs").addEventListener("click", () => {
loadLogs(true);
});
// Обработчик кнопки "Загрузить еще"
document.getElementById("loadMoreLogsBtn").addEventListener("click", () => {
loadLogs(false);
});
});

View File

@ -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%;
}
}

440
server.js
View File

@ -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();
}
}
);
}
});
}
// Добавляем 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 = `<style>
:root, html { --accent-color: ${accentColor} !important; }
* { --accent-color: ${accentColor} !important; }
</style>`;
const modifiedHtml = html.replace(
/<head>/i,
`<head>\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: "Ошибка выхода" });