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"; } // Миграции базы данных