✨ Добавлены функции для управления заметками и логирования действий пользователей
- Реализованы функции для закрепления и архивирования заметок, а также их восстановления. - Добавлены новые индексы в базу данных для улучшения производительности запросов. - Внедрено логирование действий пользователей, включая регистрацию, вход, создание, обновление и удаление заметок. - Обновлены интерфейсы для поддержки новых функций, включая кнопки для архивирования и закрепления заметок. - Оптимизированы стили и добавлены новые элементы управления для улучшения пользовательского опыта.
This commit is contained in:
parent
1172edf31e
commit
f9ba1796dc
156
public/app.js
156
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, '<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";
|
||||
});
|
||||
}
|
||||
|
||||
// Применяем цветовой акцент пользователя (только если отличается от текущего)
|
||||
|
||||
@ -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
157
public/settings.html
Normal 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">×</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
359
public/settings.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
474
public/style.css
474
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%;
|
||||
}
|
||||
}
|
||||
|
||||
440
server.js
440
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляем 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: "Ошибка выхода" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user