diff --git a/public/app.js b/public/app.js
index 02200ca..671bc0a 100644
--- a/public/app.js
+++ b/public/app.js
@@ -473,13 +473,21 @@ function insertMarkdown(tag) {
return;
}
- if (selected.startsWith(tag) && selected.endsWith(tag)) {
- // Если теги уже есть, удаляем их
+ // Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
+ const wrappingTags = ["**", "*", "`"];
+ const isWrappingTag = wrappingTags.some(wrapTag => tag.startsWith(wrapTag));
+
+ if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
+ // Если оборачивающие теги уже есть, удаляем их
noteInput.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
noteInput.setSelectionRange(start, end - 2 * tag.length);
+ } else if (!isWrappingTag && selected.startsWith(tag)) {
+ // Если одинарные теги (заголовки, списки) уже есть, удаляем их
+ noteInput.value = `${before}${selected.slice(tag.length)}${after}`;
+ noteInput.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
@@ -491,7 +499,7 @@ function insertMarkdown(tag) {
tag === "- " ||
tag === "1. " ||
tag === "> " ||
- tag === "# " ||
+ /^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
@@ -515,7 +523,7 @@ function insertMarkdown(tag) {
tag === "- " ||
tag === "1. " ||
tag === "> " ||
- tag === "# " ||
+ /^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
@@ -609,13 +617,21 @@ function insertMarkdownForEdit(textarea, tag) {
return;
}
- if (selected.startsWith(tag) && selected.endsWith(tag)) {
- // Если теги уже есть, удаляем их
+ // Определяем, какие теги оборачивают текст (нуждаются в двойных тегах)
+ const wrappingTags = ["**", "*", "`"];
+ const isWrappingTag = wrappingTags.some(wrapTag => tag.startsWith(wrapTag));
+
+ if (isWrappingTag && selected.startsWith(tag) && selected.endsWith(tag)) {
+ // Если оборачивающие теги уже есть, удаляем их
textarea.value = `${before}${selected.slice(
tag.length,
-tag.length
)}${after}`;
textarea.setSelectionRange(start, end - 2 * tag.length);
+ } else if (!isWrappingTag && selected.startsWith(tag)) {
+ // Если одинарные теги (заголовки, списки) уже есть, удаляем их
+ textarea.value = `${before}${selected.slice(tag.length)}${after}`;
+ textarea.setSelectionRange(start, end - tag.length);
} else if (selected.trim() === "") {
// Если текст не выделен
if (tag === "[Текст ссылки](URL)") {
@@ -627,7 +643,7 @@ function insertMarkdownForEdit(textarea, tag) {
tag === "- " ||
tag === "1. " ||
tag === "> " ||
- tag === "# " ||
+ /^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса помещаем курсор после тега
@@ -651,7 +667,7 @@ function insertMarkdownForEdit(textarea, tag) {
tag === "- " ||
tag === "1. " ||
tag === "> " ||
- tag === "# " ||
+ /^#{1,6} $/.test(tag) ||
tag === "- [ ] "
) {
// Для списка, цитаты, заголовка и чекбокса добавляем тег перед выделенным текстом
@@ -792,6 +808,20 @@ colorBtn.addEventListener("click", function () {
// Обработчик кнопки заголовка - открываем выпадающее меню
headerBtn.addEventListener("click", function (event) {
event.stopPropagation();
+
+ // Проверяем позицию и корректируем если нужно
+ const rect = headerDropdown.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+
+ // Если меню выходит за правую границу, позиционируем его слева
+ if (rect.right > viewportWidth) {
+ headerDropdown.style.left = 'auto';
+ headerDropdown.style.right = '0';
+ } else {
+ headerDropdown.style.left = '0';
+ headerDropdown.style.right = 'auto';
+ }
+
headerDropdown.classList.toggle("show");
});
@@ -1380,9 +1410,9 @@ async function renderNotes(notes) {
const created = parseSQLiteUtc(note.created_at);
if (note.updated_at && note.created_at !== note.updated_at) {
const updated = parseSQLiteUtc(note.updated_at);
- dateDisplay = `Создано: ${formatLocalDateTime(
+ dateDisplay = `${formatLocalDateTime(
created
- )} • Изменено: ${formatLocalDateTime(updated)}`;
+ )} ${formatLocalDateTime(updated)}`;
} else {
dateDisplay = formatLocalDateTime(created);
}
@@ -1575,7 +1605,7 @@ function addNoteEventListeners() {
// Обработчик редактирования
document.querySelectorAll("#editBtn").forEach((btn) => {
btn.addEventListener("click", function (event) {
- const noteId = event.target.dataset.id;
+ const noteId = event.target.closest("#editBtn").dataset.id;
const noteContainer = event.target.closest("#note");
const noteContent = noteContainer.querySelector(".textNote");
@@ -1596,8 +1626,9 @@ function addNoteEventListeners() {
const markdownButtons = [
{ id: "editBoldBtn", icon: "mdi:format-bold", tag: "**" },
{ id: "editItalicBtn", icon: "mdi:format-italic", tag: "*" },
+ { id: "editStrikethroughBtn", icon: "mdi:format-strikethrough", tag: "~~" },
{ id: "editColorBtn", icon: "mdi:palette", tag: "color" },
- { id: "editHeaderBtn", icon: "mdi:format-header-1", tag: "# " },
+ { id: "editHeaderBtn", icon: "mdi:format-header-pound", tag: "header" },
{ id: "editListBtn", icon: "mdi:format-list-bulleted", tag: "- " },
{
id: "editNumberedListBtn",
@@ -1617,11 +1648,66 @@ function addNoteEventListeners() {
];
markdownButtons.forEach((button) => {
- const btn = document.createElement("button");
- btn.classList.add("btnMarkdown");
- btn.id = button.id;
- btn.innerHTML = ``;
- markdownButtonsContainer.appendChild(btn);
+ if (button.tag === "header") {
+ // Создаем контейнер для кнопки заголовка с dropdown
+ const headerContainer = document.createElement("div");
+ headerContainer.classList.add("header-dropdown");
+ headerContainer.style.position = "relative";
+ headerContainer.style.display = "inline-block";
+
+ const headerBtn = document.createElement("button");
+ headerBtn.classList.add("btnMarkdown");
+ headerBtn.id = button.id;
+ headerBtn.innerHTML = `
+
+
+ `;
+
+ const headerDropdown = document.createElement("div");
+ headerDropdown.classList.add("header-dropdown-menu");
+ headerDropdown.style.display = "none";
+
+ // Создаем опции для каждого уровня заголовка
+ for (let i = 1; i <= 6; i++) {
+ const headerOption = document.createElement("button");
+ headerOption.type = "button";
+ headerOption.textContent = `H${i}`;
+ headerOption.dataset.level = i;
+ headerOption.addEventListener("click", function (e) {
+ e.stopPropagation();
+ const headerTag = "#".repeat(i) + " ";
+ insertMarkdownForEdit(textarea, headerTag);
+ headerDropdown.style.display = "none";
+ headerBtn.classList.remove("active");
+ });
+ headerDropdown.appendChild(headerOption);
+ }
+
+ // Обработчик открытия/закрытия dropdown
+ headerBtn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ headerDropdown.style.display = headerDropdown.style.display === "none" ? "block" : "none";
+ headerBtn.classList.toggle("active");
+ });
+
+ // Закрытие dropdown при клике вне его
+ document.addEventListener("click", function closeHeaderDropdown(e) {
+ if (!headerContainer.contains(e.target)) {
+ headerDropdown.style.display = "none";
+ headerBtn.classList.remove("active");
+ }
+ });
+
+ headerContainer.appendChild(headerBtn);
+ headerContainer.appendChild(headerDropdown);
+ markdownButtonsContainer.appendChild(headerContainer);
+ } else {
+ const btn = document.createElement("button");
+ btn.classList.add("btnMarkdown");
+ btn.id = button.id;
+ btn.innerHTML = ``;
+ markdownButtonsContainer.appendChild(btn);
+ }
});
// Создаем textarea с уже существующим классом textInput
@@ -2016,6 +2102,10 @@ function addNoteEventListeners() {
// Добавляем обработчики для markdown кнопок редактирования
markdownButtons.forEach((button) => {
+ if (button.tag === "header") {
+ // Header имеет собственный обработчик в dropdown
+ return;
+ }
const btn = document.getElementById(button.id);
btn.addEventListener("click", function () {
if (button.tag === "image") {
@@ -2098,6 +2188,7 @@ function addTagClickListeners() {
// Перерисовываем заметки и теги
await renderNotes(allNotes);
renderTags();
+ renderTagsMobile();
updateFilterIndicator();
});
});
@@ -2743,7 +2834,7 @@ function renderCalendar() {
const firstDay = new Date(year, month, 1);
// Получаем последний день месяца
const lastDay = new Date(year, month + 1, 0);
- // Получаем день недели первого дня (0 - воскресенье, 1 - понедельник и т.д.)
+ // Получаем день недели первого дня (0 - воскресенье, 1 - воскресенье, 1 - понедельник и т.д.)
let firstDayOfWeek = firstDay.getDay();
// Преобразуем так, чтобы понедельник был первым днем (0)
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
@@ -2751,11 +2842,17 @@ function renderCalendar() {
// Очищаем календарь
calendarDays.innerHTML = "";
- // Создаём Set дат, когда были созданы заметки (используем created_at)
- const noteDates = new Set();
+ // Создаём Set дат, когда были созданы заметки (зеленые кружки)
+ const createdNoteDates = new Set();
+ // Создаём Set дат, когда были отредактированы заметки (желтые кружки)
+ const editedNoteDates = new Set();
+
allNotes.forEach((note) => {
if (note.created_at) {
- noteDates.add(formatDateFromTimestamp(note.created_at));
+ createdNoteDates.add(formatDateFromTimestamp(note.created_at));
+ }
+ if (note.updated_at && note.created_at !== note.updated_at) {
+ editedNoteDates.add(formatDateFromTimestamp(note.updated_at));
}
});
@@ -2777,9 +2874,12 @@ function renderCalendar() {
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
@@ -2817,9 +2917,12 @@ function renderCalendar() {
}
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
@@ -2852,9 +2955,12 @@ function renderCalendar() {
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
@@ -2945,7 +3051,9 @@ window.clearFilter = async function () {
await renderNotes(allNotes);
renderCalendar();
+ renderCalendarMobile();
renderTags();
+ renderTagsMobile();
updateFilterIndicator();
};
@@ -3072,11 +3180,19 @@ function renderCalendarMobile() {
// Очищаем календарь
calendarDays.innerHTML = "";
- // Создаём Set дат, когда были созданы заметки (используем created_at)
- const noteDates = new Set();
+ // Создаём Set дат, когда были созданы заметки (зеленые кружки)
+ const createdNoteDates = new Set();
allNotes.forEach((note) => {
if (note.created_at) {
- noteDates.add(formatDateFromTimestamp(note.created_at));
+ createdNoteDates.add(formatDateFromTimestamp(note.created_at));
+ }
+ });
+
+ // Создаём Set дат, когда были отредактированы заметки (желтые кружки)
+ const editedNoteDates = new Set();
+ allNotes.forEach((note) => {
+ if (note.updated_at && note.created_at !== note.updated_at) {
+ editedNoteDates.add(formatDateFromTimestamp(note.updated_at));
}
});
@@ -3098,9 +3214,12 @@ function renderCalendarMobile() {
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
@@ -3138,9 +3257,12 @@ function renderCalendarMobile() {
}
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
@@ -3173,9 +3295,12 @@ function renderCalendarMobile() {
dayDiv.dataset.date = dateStr;
// Проверяем, есть ли заметки на этот день
- if (noteDates.has(dateStr)) {
+ if (createdNoteDates.has(dateStr)) {
dayDiv.classList.add("has-notes");
}
+ if (editedNoteDates.has(dateStr)) {
+ dayDiv.classList.add("has-edited-notes");
+ }
// Проверяем, выбран ли этот день
if (selectedDateFilter === dateStr) {
diff --git a/public/style.css b/public/style.css
index 4e57d1d..5683278 100644
--- a/public/style.css
+++ b/public/style.css
@@ -419,7 +419,7 @@ header {
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
- overflow: hidden;
+ overflow: visible;
}
.login-form {
@@ -808,6 +808,8 @@ textarea:focus {
.markdown-buttons {
margin-top: 10px;
margin-bottom: 10px;
+ overflow: visible;
+ position: relative;
}
.markdown-buttons .btnMarkdown {
@@ -837,20 +839,22 @@ textarea:focus {
.header-dropdown {
position: relative;
display: inline-block;
+ overflow: visible;
}
.header-dropdown-menu {
display: none;
position: absolute;
top: 100%;
- left: 0;
+ right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
- z-index: 100;
+ z-index: 1000;
margin-top: 2px;
- min-width: 80px;
+ min-width: 60px;
+ max-width: 120px;
}
.header-dropdown-menu.show {
@@ -883,6 +887,14 @@ textarea:focus {
border-radius: 0 0 5px 5px;
}
+/* Адаптивность для выпадающего меню заголовков */
+@media (max-width: 768px) {
+ .header-dropdown-menu {
+ min-width: 50px;
+ right: -10px; /* Смещаем чуть левее для лучшего позиционирования на мобильных */
+ }
+}
+
.footer {
text-align: center;
font-size: 12px;
@@ -1168,7 +1180,7 @@ textarea:focus {
font-weight: bold;
}
-/* Индикатор для дней с заметками */
+/* Индикатор для дней с заметками (зеленый кружок) */
.calendar-day.has-notes::after {
content: "";
position: absolute;
@@ -1181,14 +1193,68 @@ textarea:focus {
border-radius: 50%;
}
+/* Индикатор для дней с отредактированными заметками (желтый кружок) */
+.calendar-day.has-edited-notes::before {
+ content: "";
+ position: absolute;
+ bottom: 2px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 4px;
+ height: 4px;
+ background-color: #ffc107;
+ border-radius: 50%;
+}
+
+/* Когда есть оба типа заметок - сдвигаем кружки в стороны */
+.calendar-day.has-notes.has-edited-notes::before {
+ transform: translateX(-150%);
+}
+
+.calendar-day.has-notes.has-edited-notes::after {
+ transform: translateX(50%);
+}
+
/* Индикатор для выбранного дня с заметками */
.calendar-day.selected.has-notes::after {
- background-color: #fff;
+ background-color: #28a745;
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+}
+
+/* Индикатор для выбранного дня с отредактированными заметками */
+.calendar-day.selected.has-edited-notes::before {
+ background-color: #ffc107;
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
/* Индикатор для сегодняшнего дня с заметками */
.calendar-day.today.has-notes::after {
- background-color: #fff;
+ background-color: #28a745;
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+}
+
+/* Индикатор для сегодняшнего дня с отредактированными заметками */
+.calendar-day.today.has-edited-notes::before {
+ background-color: #ffc107;
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+}
+
+/* Для дней с обоими типами заметок в выбранном состоянии */
+.calendar-day.selected.has-notes.has-edited-notes::before,
+.calendar-day.selected.has-notes.has-edited-notes::after {
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+}
+
+/* Для дней с обоими типами заметок в сегодняшнем состоянии */
+.calendar-day.today.has-notes.has-edited-notes::before,
+.calendar-day.today.has-notes.has-edited-notes::after {
+ border: 1px solid #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
/* Стили для секции тегов */
diff --git a/server.js b/server.js
index 940ab4d..5366027 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,9 @@ require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 3000;
+// Настройка trust proxy для правильного получения IP адресов через прокси
+app.set('trust proxy', true);
+
// Создаем директорию для аватарок, если её нет
const uploadsDir = path.join(__dirname, "public", "uploads");
if (!fs.existsSync(uploadsDir)) {
@@ -293,28 +296,51 @@ function logAction(userId, actionType, details, ipAddress) {
// Функция для получения IP-адреса клиента
function getClientIP(req) {
// Проверяем различные заголовки, которые могут содержать внешний IP-адрес
- // Приоритет: x-forwarded-for, x-real-ip, cf-connecting-ip (Cloudflare)
- let ip =
+ // Приоритет: x-forwarded-for, x-real-ip, x-client-ip, cf-connecting-ip (Cloudflare)
+ let ip =
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
req.headers["x-real-ip"] ||
+ req.headers["x-client-ip"] ||
req.headers["cf-connecting-ip"] || // Для Cloudflare
- req.headers["x-forwarded-for"]?.split(",")[0].trim() || // Повтор для надежности
req.headers["x-cluster-client-ip"] || // Для кластеров
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
"unknown";
-
- // Убираем порт из IPv6 адреса, если есть
- if (ip.includes(":") && ip.split(":").length > 2) {
- // Это IPv6 адрес, убираем порт
- ip = ip.split(":").slice(0, -1).join(":").replace(/[[\]]/g, "");
- } else if (ip.includes(":")) {
- // Это IPv4 адрес с портом, убираем порт
- ip = ip.split(":")[0];
+
+ // Очищаем IP от скобок IPv6 и портов
+ if (ip && ip !== "unknown") {
+ // Убираем скобки IPv6
+ ip = ip.replace(/[[\]]/g, "");
+
+ // Проверяем, является ли это IPv6 адресом
+ if (ip.includes("::")) {
+ // Это IPv6 адрес (содержит "::" или несколько ":")
+ // IPv6 адреса могут быть в формате [::1]:port или ::1
+ const ipv6Match = ip.match(/^(\[)?([^\]]+)(\])?(:(\d+))?$/);
+ if (ipv6Match) {
+ ip = ipv6Match[2]; // Берем IPv6 адрес без скобок и порта
+ }
+ } else if (ip.includes(":") && !ip.includes(".")) {
+ // IPv6 адрес без "::" но с несколькими ":"
+ // Например, 2001:db8::1
+ // Оставляем как есть
+ } else if (ip.includes(":")) {
+ // IPv4 с портом (например, 192.168.1.1:3000)
+ const parts = ip.split(":");
+ if (parts.length === 2 && /^\d+$/.test(parts[1])) {
+ ip = parts[0];
+ }
+ }
+ // IPv4 без порта оставляем как есть
}
-
- return ip;
+
+ // Конвертируем IPv6 localhost в IPv4 для лучшей читаемости
+ if (ip === "::1") {
+ ip = "127.0.0.1";
+ }
+
+ return ip || "unknown";
}
// Миграции базы данных