✨ Добавлены функции для управления заметками и логирования действий пользователей
- Реализованы функции для закрепления и архивирования заметок, а также их восстановления. - Добавлены новые индексы в базу данных для улучшения производительности запросов. - Внедрено логирование действий пользователей, включая регистрацию, вход, создание, обновление и удаление заметок. - Обновлены интерфейсы для поддержки новых функций, включая кнопки для архивирования и закрепления заметок. - Оптимизированы стили и добавлены новые элементы управления для улучшения пользовательского опыта.
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
|
// Получаем кнопки markdown
|
||||||
const boldBtn = document.getElementById("boldBtn");
|
const boldBtn = document.getElementById("boldBtn");
|
||||||
const italicBtn = document.getElementById("italicBtn");
|
const italicBtn = document.getElementById("italicBtn");
|
||||||
|
const strikethroughBtn = document.getElementById("strikethroughBtn");
|
||||||
const colorBtn = document.getElementById("colorBtn");
|
const colorBtn = document.getElementById("colorBtn");
|
||||||
const headerBtn = document.getElementById("headerBtn");
|
const headerBtn = document.getElementById("headerBtn");
|
||||||
|
const headerDropdown = document.getElementById("headerDropdown");
|
||||||
const listBtn = document.getElementById("listBtn");
|
const listBtn = document.getElementById("listBtn");
|
||||||
const numberedListBtn = document.getElementById("numberedListBtn");
|
const numberedListBtn = document.getElementById("numberedListBtn");
|
||||||
const quoteBtn = document.getElementById("quoteBtn");
|
const quoteBtn = document.getElementById("quoteBtn");
|
||||||
@ -16,6 +18,9 @@ const linkBtn = document.getElementById("linkBtn");
|
|||||||
const checkboxBtn = document.getElementById("checkboxBtn");
|
const checkboxBtn = document.getElementById("checkboxBtn");
|
||||||
const imageBtn = document.getElementById("imageBtn");
|
const imageBtn = document.getElementById("imageBtn");
|
||||||
|
|
||||||
|
// Кнопка настроек
|
||||||
|
const settingsBtn = document.getElementById("settings-btn");
|
||||||
|
|
||||||
// Элементы для загрузки изображений
|
// Элементы для загрузки изображений
|
||||||
const imageInput = document.getElementById("imageInput");
|
const imageInput = document.getElementById("imageInput");
|
||||||
const imagePreviewContainer = document.getElementById("imagePreviewContainer");
|
const imagePreviewContainer = document.getElementById("imagePreviewContainer");
|
||||||
@ -698,12 +703,34 @@ italicBtn.addEventListener("click", function () {
|
|||||||
insertMarkdown("*");
|
insertMarkdown("*");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
strikethroughBtn.addEventListener("click", function () {
|
||||||
|
insertMarkdown("~~");
|
||||||
|
});
|
||||||
|
|
||||||
colorBtn.addEventListener("click", function () {
|
colorBtn.addEventListener("click", function () {
|
||||||
insertColorTag();
|
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 () {
|
listBtn.addEventListener("click", function () {
|
||||||
@ -1129,7 +1156,7 @@ function highlightSearchText(content, query) {
|
|||||||
return content.replace(regex, '<span class="search-highlight">$1</span>');
|
return content.replace(regex, '<span class="search-highlight">$1</span>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Настройка marked.js для поддержки чекбоксов
|
// Настройка marked.js для поддержки чекбоксов и strikethrough
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
// Переопределяем рендеринг списков, чтобы чекбоксы были кликабельными (без disabled)
|
// Переопределяем рендеринг списков, чтобы чекбоксы были кликабельными (без disabled)
|
||||||
@ -1147,7 +1174,7 @@ renderer.listitem = function (text, task, checked) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
gfm: true, // GitHub Flavored Markdown
|
gfm: true, // GitHub Flavored Markdown (включает strikethrough)
|
||||||
breaks: true,
|
breaks: true,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
html: true, // Разрешить HTML теги
|
html: true, // Разрешить HTML теги
|
||||||
@ -1240,16 +1267,26 @@ async function renderNotes(notes) {
|
|||||||
dateDisplay = `${note.date} ${note.time}`;
|
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 = `
|
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">
|
<div class="date">
|
||||||
<span class="date-text">${dateDisplay}</span>
|
<span class="date-text">${dateDisplay}${pinIndicator}</span>
|
||||||
<div id="editBtn" class="notesHeaderBtn" data-id="${
|
<div class="note-actions">
|
||||||
note.id
|
<div id="pinBtn" class="notesHeaderBtn" data-id="${note.id}" title="${note.is_pinned ? "Открепить" : "Закрепить"}">
|
||||||
}">Ред.</div>
|
<span class="iconify" data-icon="mdi:pin${note.is_pinned ? "-off" : ""}"></span>
|
||||||
<div id="deleteBtn" class="notesHeaderBtn" data-id="${
|
</div>
|
||||||
note.id
|
<div id="archiveBtn" class="notesHeaderBtn" data-id="${note.id}" title="Архивировать">
|
||||||
}">Удал.</div>
|
<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>
|
||||||
<div class="textNote" data-original-content="${note.content.replace(
|
<div class="textNote" data-original-content="${note.content.replace(
|
||||||
/"/g,
|
/"/g,
|
||||||
@ -1326,6 +1363,54 @@ function handleLongNotes() {
|
|||||||
|
|
||||||
// Функция для добавления обработчиков событий к заметкам
|
// Функция для добавления обработчиков событий к заметкам
|
||||||
function addNoteEventListeners() {
|
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) => {
|
document.querySelectorAll("#deleteBtn").forEach((btn) => {
|
||||||
btn.addEventListener("click", async function (event) {
|
btn.addEventListener("click", async function (event) {
|
||||||
@ -2257,6 +2342,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
// Добавляем обработчик для кнопки выхода
|
// Добавляем обработчик для кнопки выхода
|
||||||
setupLogoutHandler();
|
setupLogoutHandler();
|
||||||
|
|
||||||
|
// Обработчик для кнопки настроек
|
||||||
|
if (settingsBtn) {
|
||||||
|
settingsBtn.addEventListener("click", function () {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция для настройки обработчика выхода
|
// Функция для настройки обработчика выхода
|
||||||
@ -2314,25 +2406,39 @@ async function loadUserInfo() {
|
|||||||
const response = await fetch("/api/user");
|
const response = await fetch("/api/user");
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const user = await response.json();
|
const user = await response.json();
|
||||||
const usernameDisplay = document.getElementById("username-display");
|
|
||||||
const userAvatar = document.getElementById("user-avatar");
|
const userAvatar = document.getElementById("user-avatar");
|
||||||
const userAvatarContainer = document.getElementById(
|
const userAvatarContainer = document.getElementById("user-avatar-container");
|
||||||
"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";
|
if (userAvatarContainer) {
|
||||||
usernameDisplay.addEventListener("click", function () {
|
userAvatarContainer.style.cursor = "pointer";
|
||||||
|
userAvatarContainer.addEventListener("click", function () {
|
||||||
window.location.href = "/profile";
|
window.location.href = "/profile";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (userAvatarPlaceholder) {
|
||||||
// Аватарка скрыта на странице заметок
|
userAvatarPlaceholder.addEventListener("click", function () {
|
||||||
if (userAvatarContainer) {
|
window.location.href = "/profile";
|
||||||
userAvatarContainer.style.display = "none";
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Применяем цветовой акцент пользователя (только если отличается от текущего)
|
// Применяем цветовой акцент пользователя (только если отличается от текущего)
|
||||||
|
|||||||
@ -222,33 +222,41 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div
|
<div
|
||||||
id="user-avatar-container"
|
id="user-avatar-container"
|
||||||
style="display: none; margin-right: 10px"
|
class="user-avatar-mini"
|
||||||
>
|
style="display: none"
|
||||||
<img
|
title="Перейти в профиль"
|
||||||
id="user-avatar"
|
>
|
||||||
src=""
|
<img
|
||||||
alt="Аватар"
|
id="user-avatar"
|
||||||
loading="lazy"
|
src=""
|
||||||
style="
|
alt="Аватар"
|
||||||
width: 32px;
|
loading="lazy"
|
||||||
height: 32px;
|
/>
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
vertical-align: middle;
|
|
||||||
border: 2px solid #007bff;
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span id="username-display" class="username-clickable"></span>
|
|
||||||
<form action="/logout" method="POST" style="display: inline">
|
|
||||||
<button type="submit" class="logout-btn">
|
|
||||||
<span class="iconify" data-icon="mdi:logout"></span> Выйти
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<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> Выйти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="markdown-buttons">
|
<div class="markdown-buttons">
|
||||||
@ -258,12 +266,26 @@
|
|||||||
<button class="btnMarkdown" id="italicBtn" title="Курсив">
|
<button class="btnMarkdown" id="italicBtn" title="Курсив">
|
||||||
<span class="iconify" data-icon="mdi:format-italic"></span>
|
<span class="iconify" data-icon="mdi:format-italic"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btnMarkdown" id="strikethroughBtn" title="Перечеркнутый">
|
||||||
|
<span class="iconify" data-icon="mdi:format-strikethrough"></span>
|
||||||
|
</button>
|
||||||
<button class="btnMarkdown" id="colorBtn" title="Цвет текста">
|
<button class="btnMarkdown" id="colorBtn" title="Цвет текста">
|
||||||
<span class="iconify" data-icon="mdi:palette"></span>
|
<span class="iconify" data-icon="mdi:palette"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btnMarkdown" id="headerBtn" title="Заголовок">
|
<div class="header-dropdown">
|
||||||
<span class="iconify" data-icon="mdi:format-header-1"></span>
|
<button class="btnMarkdown" id="headerBtn" title="Заголовок">
|
||||||
</button>
|
<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="Список">
|
<button class="btnMarkdown" id="listBtn" title="Список">
|
||||||
<span class="iconify" data-icon="mdi:format-list-bulleted"></span>
|
<span class="iconify" data-icon="mdi:format-list-bulleted"></span>
|
||||||
</button>
|
</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;
|
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 {
|
.logout-btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -376,25 +440,67 @@ textarea:focus {
|
|||||||
.date {
|
.date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: grey;
|
color: grey;
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date .date-text {
|
.date .date-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.notesHeaderBtn {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
white-space: nowrap;
|
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 {
|
.textNote {
|
||||||
@ -662,6 +768,56 @@ textarea:focus {
|
|||||||
background-color: #e0e0e0;
|
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 {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -1710,3 +1866,315 @@ textarea:focus {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
442
server.js
442
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_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_updated_at ON notes(updated_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_notes_date ON notes(date)",
|
"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_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_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) => {
|
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() {
|
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 и добавляем её если нужно
|
// Проверяем существование колонки accent_color и добавляем её если нужно
|
||||||
db.all("PRAGMA table_info(users)", (err, columns) => {
|
db.all("PRAGMA table_info(users)", (err, columns) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -300,7 +350,7 @@ function runMigrations() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем существование колонки updated_at в таблице notes и добавляем её если нужно
|
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
||||||
db.all("PRAGMA table_info(notes)", (err, columns) => {
|
db.all("PRAGMA table_info(notes)", (err, columns) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка проверки структуры таблицы notes:", err.message);
|
console.error("Ошибка проверки структуры таблицы notes:", err.message);
|
||||||
@ -308,13 +358,16 @@ function runMigrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdatedAt = columns.some((col) => col.name === "updated_at");
|
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) {
|
if (!hasUpdatedAt) {
|
||||||
db.run("ALTER TABLE notes ADD COLUMN updated_at DATETIME", (err) => {
|
db.run("ALTER TABLE notes ADD COLUMN updated_at DATETIME", (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка добавления колонки updated_at:", err.message);
|
console.error("Ошибка добавления колонки updated_at:", err.message);
|
||||||
} else {
|
} else {
|
||||||
console.log("Колонка updated_at добавлена в таблицу notes");
|
console.log("Колонка updated_at добавлена в таблицу notes");
|
||||||
// Устанавливаем updated_at = created_at для существующих записей
|
|
||||||
db.run(
|
db.run(
|
||||||
"UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL",
|
"UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL",
|
||||||
(err) => {
|
(err) => {
|
||||||
@ -323,20 +376,50 @@ function runMigrations() {
|
|||||||
"Ошибка обновления updated_at для существующих записей:",
|
"Ошибка обновления updated_at для существующих записей:",
|
||||||
err.message
|
err.message
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"Колонка updated_at обновлена для существующих записей"
|
|
||||||
);
|
|
||||||
// Создаем индексы после добавления колонки
|
|
||||||
createIndexes();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
// Если колонка уже существует, просто создаем индексы
|
|
||||||
|
// Добавляем is_pinned если нужно
|
||||||
|
if (!hasPinned) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE notes ADD COLUMN is_pinned INTEGER DEFAULT 0",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка добавления колонки is_pinned:", err.message);
|
||||||
|
} else {
|
||||||
|
console.log("Колонка is_pinned добавлена в таблицу notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем is_archived если нужно
|
||||||
|
if (!hasArchived) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE notes ADD COLUMN is_archived INTEGER DEFAULT 0",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка добавления колонки is_archived:",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("Колонка is_archived добавлена в таблицу notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем индексы после всех изменений
|
||||||
|
if (hasUpdatedAt && hasPinned && hasArchived) {
|
||||||
createIndexes();
|
createIndexes();
|
||||||
|
} else {
|
||||||
|
// Задержка для завершения миграций
|
||||||
|
setTimeout(createIndexes, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -412,6 +495,11 @@ app.post("/api/register", async (req, res) => {
|
|||||||
req.session.userId = this.lastID;
|
req.session.userId = this.lastID;
|
||||||
req.session.username = username;
|
req.session.username = username;
|
||||||
req.session.authenticated = true;
|
req.session.authenticated = true;
|
||||||
|
|
||||||
|
// Логируем регистрацию
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
logAction(this.lastID, "register", `Регистрация нового пользователя`, clientIP);
|
||||||
|
|
||||||
res.json({ success: true, message: "Регистрация успешна" });
|
res.json({ success: true, message: "Регистрация успешна" });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -450,6 +538,11 @@ app.post("/api/login", async (req, res) => {
|
|||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
req.session.username = user.username;
|
req.session.username = user.username;
|
||||||
req.session.authenticated = true;
|
req.session.authenticated = true;
|
||||||
|
|
||||||
|
// Логируем вход
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
logAction(user.id, "login", `Вход в систему`, clientIP);
|
||||||
|
|
||||||
res.json({ success: true, message: "Вход успешен" });
|
res.json({ success: true, message: "Вход успешен" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Ошибка при сравнении паролей:", 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) => {
|
app.get("/api/notes", requireAuth, (req, res) => {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
@ -630,9 +723,9 @@ app.get("/api/notes", requireAuth, (req, res) => {
|
|||||||
END as images
|
END as images
|
||||||
FROM notes n
|
FROM notes n
|
||||||
LEFT JOIN note_images ni ON n.id = ni.note_id
|
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
|
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) => {
|
db.all(sql, [req.session.userId], (err, rows) => {
|
||||||
@ -668,7 +761,13 @@ app.post("/api/notes", requireAuth, (req, res) => {
|
|||||||
console.error("Ошибка создания заметки:", err.message);
|
console.error("Ошибка создания заметки:", err.message);
|
||||||
return res.status(500).json({ error: "Ошибка сервера" });
|
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) {
|
if (this.changes === 0) {
|
||||||
return res.status(404).json({ error: "Заметка не найдена" });
|
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 });
|
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) {
|
if (this.changes === 0) {
|
||||||
return res.status(404).json({ error: "Заметка не найдена" });
|
return res.status(404).json({ error: "Заметка не найдена" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логируем удаление заметки
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
logAction(req.session.userId, "note_delete", `Удалена заметка #${id}`, clientIP);
|
||||||
|
|
||||||
res.json({ message: "Заметка удалена" });
|
res.json({ message: "Заметка удалена" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -1091,6 +1200,16 @@ app.put("/api/user/profile", requireAuth, async (req, res) => {
|
|||||||
req.session.username = username;
|
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: "Профиль успешно обновлен" });
|
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) => {
|
app.post("/logout", (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
|
||||||
|
// Логируем выход
|
||||||
|
if (userId) {
|
||||||
|
logAction(userId, "logout", "Выход из системы", clientIP);
|
||||||
|
}
|
||||||
|
|
||||||
req.session.destroy((err) => {
|
req.session.destroy((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: "Ошибка выхода" });
|
return res.status(500).json({ error: "Ошибка выхода" });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user