From 6f79afeb7ebde2765cd2d1be69ae4dd9bda2295a Mon Sep 17 00:00:00 2001 From: Fovway Date: Sun, 19 Oct 2025 15:54:47 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=91=D0=BC,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=BE=D0=BC=20=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена кнопка открытия слайдера (☰) на мобильных устройствах - Реализован удобный боковой слайдер для мобильной версии - Слайдер содержит полностью функциональный календарь с навигацией - Поле поиска синхронизировано с ПК версией - Теги отображаются с количеством заметок - Возможность закрытия слайдера кнопкой или оверлеем - Все функции работают как в ПК версии - Добавлена синхронизация между мобильным и ПК календарями - Обновлена документация README с описанием мобильной версии --- README.md | 28 ++++ public/app.js | 364 ++++++++++++++++++++++++++++++++++++++++++++++ public/notes.html | 70 +++++++++ public/style.css | 104 +++++++++++++ 4 files changed, 566 insertions(+) diff --git a/README.md b/README.md index 9d8578f..bcf1a43 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ - 🏷️ **Система тегов с автоматическим извлечением из заметок** (NEW!) - 🔍 **Поиск по заметкам с подсветкой результатов** (NEW!) - 📅 **Мини-календарь для навигации по датам заметок** (NEW!) +- 📱 **Адаптивный дизайн с боковым слайдером на мобильных** (NEW!) +- ☰ **Мобильный боковой слайдер с календарём, поиском и тегами** (NEW!) - 🎨 Простой и интуитивный интерфейс - 📱 Адаптивный дизайн @@ -128,6 +130,32 @@ npm start Нажмите кнопку "🚪 Выйти" в верхней части страницы заметок +## Мобильная версия + +### Боковой слайдер на мобильных устройствах + +На мобильных устройствах (ширина экрана до 768 пикселей) вместо постоянной левой панели появляется удобный **боковой слайдер** с кнопкой "☰" в левом верхнем углу. + +**Функциональность мобильного слайдера:** + +1. **Кнопка открытия** - нажмите на кнопку "☰" в левом верхнем углу экрана +2. **Содержимое слайдера** включает все основные инструменты: + - 📅 **Календарь** - полностью функциональный календарь месяца с навигацией + - 🔍 **Поле поиска** - синхронизировано с основным полем поиска + - 🏷️ **Теги** - все теги с количеством заметок +3. **Закрытие слайдера**: + - Нажмите кнопку ✕ в верхнем правом углу слайдера + - Нажмите на серую область (оверлей) справа +4. **Синхронизация** - изменения в слайдере (выбор даты, ввод текста, клик на тег) автоматически синхронизируются с основной ПК версией + +**Преимущества мобильного слайдера:** + +- ✅ Полная функциональность как в ПК версии +- ✅ Экономия места на экране мобильного устройства +- ✅ Легкое открытие/закрытие +- ✅ Удобная навигация по заметкам +- ✅ Не блокирует контент при открытии (использует оверлей) + ## Структура проекта ``` diff --git a/public/app.js b/public/app.js index ecb498d..b5b74ca 100644 --- a/public/app.js +++ b/public/app.js @@ -1116,3 +1116,367 @@ function initSearch() { } }); } + +// ==================== МОБИЛЬНЫЙ СЛАЙДЕР ==================== + +// Функция для отображения календаря в мобильном слайдере +function renderCalendarMobile() { + const calendarDays = document.getElementById("calendarDaysMobile"); + const monthYear = document.getElementById("monthYearMobile"); + + // Проверяем, существуют ли элементы календаря для мобильной версии + if (!calendarDays || !monthYear) return; + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // Массив названий месяцев + const monthNames = [ + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", + ]; + + // Устанавливаем заголовок месяца и года + monthYear.textContent = `${monthNames[month]} ${year}`; + + // Получаем первый день месяца + const firstDay = new Date(year, month, 1); + // Получаем последний день месяца + const lastDay = new Date(year, month + 1, 0); + // Получаем день недели первого дня (0 - воскресенье, 1 - понедельник и т.д.) + let firstDayOfWeek = firstDay.getDay(); + // Преобразуем так, чтобы понедельник был первым днем (0) + firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; + + // Очищаем календарь + calendarDays.innerHTML = ""; + + // Создаём Set дат, когда были созданы заметки + const noteDates = new Set(); + allNotes.forEach((note) => { + noteDates.add(note.date); + }); + + // Получаем последний день предыдущего месяца + const prevMonthLastDay = new Date(year, month, 0).getDate(); + const prevMonth = month === 0 ? 11 : month - 1; + const prevYear = month === 0 ? year - 1 : year; + + // Добавляем дни предыдущего месяца + for (let i = firstDayOfWeek - 1; i >= 0; i--) { + const day = prevMonthLastDay - i; + const dateStr = `${String(day).padStart(2, "0")}.${String( + prevMonth + 1 + ).padStart(2, "0")}.${prevYear}`; + + const dayDiv = document.createElement("div"); + dayDiv.classList.add("calendar-day", "other-month"); + dayDiv.textContent = day; + dayDiv.dataset.date = dateStr; + + // Проверяем, есть ли заметки на этот день + if (noteDates.has(dateStr)) { + dayDiv.classList.add("has-notes"); + } + + // Проверяем, выбран ли этот день + if (selectedDateFilter === dateStr) { + dayDiv.classList.add("selected"); + } + + // Добавляем обработчик клика + dayDiv.addEventListener("click", handleDayClickMobile); + + calendarDays.appendChild(dayDiv); + } + + // Добавляем дни текущего месяца + const today = new Date(); + for (let day = 1; day <= lastDay.getDate(); day++) { + const dateStr = `${String(day).padStart(2, "0")}.${String( + month + 1 + ).padStart(2, "0")}.${year}`; + + const dayDiv = document.createElement("div"); + dayDiv.classList.add("calendar-day"); + dayDiv.textContent = day; + dayDiv.dataset.date = dateStr; + + // Проверяем, является ли день сегодняшним + if ( + day === today.getDate() && + month === today.getMonth() && + year === today.getFullYear() + ) { + dayDiv.classList.add("today"); + } + + // Проверяем, есть ли заметки на этот день + if (noteDates.has(dateStr)) { + dayDiv.classList.add("has-notes"); + } + + // Проверяем, выбран ли этот день + if (selectedDateFilter === dateStr) { + dayDiv.classList.add("selected"); + } + + // Добавляем обработчик клика + dayDiv.addEventListener("click", handleDayClickMobile); + + calendarDays.appendChild(dayDiv); + } + + // Добавляем дни следующего месяца + const totalCells = calendarDays.children.length; + const remainingCells = 42 - totalCells; // 6 недель по 7 дней + const nextMonth = month === 11 ? 0 : month + 1; + const nextYear = month === 11 ? year + 1 : year; + + for (let day = 1; day <= remainingCells; day++) { + const dateStr = `${String(day).padStart(2, "0")}.${String( + nextMonth + 1 + ).padStart(2, "0")}.${nextYear}`; + + const dayDiv = document.createElement("div"); + dayDiv.classList.add("calendar-day", "other-month"); + dayDiv.textContent = day; + dayDiv.dataset.date = dateStr; + + // Проверяем, есть ли заметки на этот день + if (noteDates.has(dateStr)) { + dayDiv.classList.add("has-notes"); + } + + // Проверяем, выбран ли этот день + if (selectedDateFilter === dateStr) { + dayDiv.classList.add("selected"); + } + + // Добавляем обработчик клика + dayDiv.addEventListener("click", handleDayClickMobile); + + calendarDays.appendChild(dayDiv); + } +} + +// Обработчик клика на день в календаре для мобильной версии +function handleDayClickMobile(event) { + const clickedDate = event.target.dataset.date; + + // Если кликнули на тот же день, снимаем фильтр + if (selectedDateFilter === clickedDate) { + selectedDateFilter = null; + } else { + selectedDateFilter = clickedDate; + } + + // Перерисовываем заметки, оба календаря и теги + renderNotes(allNotes); + renderCalendar(); + renderCalendarMobile(); + renderTags(); + renderTagsMobile(); + updateFilterIndicator(); +} + +// Функция для отображения тегов в мобильном слайдере +function renderTagsMobile() { + const tagsContainer = document.getElementById("tagsContainerMobile"); + if (!tagsContainer) return; + + const tagCounts = getAllTags(allNotes); + const sortedTags = Object.keys(tagCounts).sort(); + + if (sortedTags.length === 0) { + tagsContainer.innerHTML = + '
Нет тегов
'; + return; + } + + tagsContainer.innerHTML = sortedTags + .map((tag) => { + const count = tagCounts[tag]; + const isActive = selectedTagFilter === tag ? "active" : ""; + return `#${tag}${count}`; + }) + .join(""); + + // Добавляем обработчики кликов для тегов + tagsContainer.querySelectorAll(".tag").forEach((tagElement) => { + tagElement.addEventListener("click", handleTagClickMobile); + }); +} + +// Обработчик клика на тег в мобильном слайдере +function handleTagClickMobile(event) { + const clickedTag = event.target.closest(".tag").dataset.tag; + + // Если кликнули на тот же тег, снимаем фильтр + if (selectedTagFilter === clickedTag) { + selectedTagFilter = null; + } else { + selectedTagFilter = clickedTag; + } + + // Перерисовываем заметки, теги и оба календаря + renderNotes(allNotes); + renderTags(); + renderTagsMobile(); + renderCalendar(); + renderCalendarMobile(); + updateFilterIndicator(); +} + +// Инициализация мобильного слайдера +document.addEventListener("DOMContentLoaded", function () { + const mobileMenuBtn = document.getElementById("mobileMenuBtn"); + const sidebarCloseBtn = document.getElementById("sidebarCloseBtn"); + const mobileSidebar = document.getElementById("mobileSidebar"); + const sidebarOverlay = document.getElementById("sidebarOverlay"); + + // Открытие слайдера при клике на кнопку меню + if (mobileMenuBtn) { + mobileMenuBtn.addEventListener("click", function () { + mobileSidebar.classList.add("open"); + sidebarOverlay.classList.add("open"); + document.body.style.overflow = "hidden"; + }); + } + + // Закрытие слайдера при клике на кнопку закрытия + if (sidebarCloseBtn) { + sidebarCloseBtn.addEventListener("click", function () { + mobileSidebar.classList.remove("open"); + sidebarOverlay.classList.remove("open"); + document.body.style.overflow = "auto"; + }); + } + + // Закрытие слайдера при клике на оверлей + if (sidebarOverlay) { + sidebarOverlay.addEventListener("click", function () { + mobileSidebar.classList.remove("open"); + sidebarOverlay.classList.remove("open"); + document.body.style.overflow = "auto"; + }); + } + + // Инициализация мобильного поиска + initSearchMobile(); + + // Инициализация мобильного календаря + renderCalendarMobile(); + renderTagsMobile(); + + // Обработчики для кнопок навигации мобильного календаря + const prevMonthBtnMobile = document.getElementById("prevMonthMobile"); + const nextMonthBtnMobile = document.getElementById("nextMonthMobile"); + + if (prevMonthBtnMobile) { + prevMonthBtnMobile.addEventListener("click", function () { + currentDate.setMonth(currentDate.getMonth() - 1); + renderCalendar(); + renderCalendarMobile(); + }); + } + + if (nextMonthBtnMobile) { + nextMonthBtnMobile.addEventListener("click", function () { + currentDate.setMonth(currentDate.getMonth() + 1); + renderCalendar(); + renderCalendarMobile(); + }); + } +}); + +// Функция для инициализации мобильного поиска +function initSearchMobile() { + const searchInput = document.getElementById("searchInputMobile"); + const clearSearchBtn = document.getElementById("clearSearchBtnMobile"); + + if (!searchInput || !clearSearchBtn) return; + + // Обработчик ввода в поле поиска с задержкой + let searchTimeout; + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + const query = this.value; + + // Показываем/скрываем кнопку очистки + if (query.trim()) { + clearSearchBtn.style.display = "block"; + } else { + clearSearchBtn.style.display = "none"; + } + + // Задержка перед поиском для оптимизации + searchTimeout = setTimeout(() => { + searchNotes(query); + // Обновляем основное поле поиска + const mainSearchInput = document.getElementById("searchInput"); + if (mainSearchInput) { + mainSearchInput.value = query; + } + const mainClearSearchBtn = document.getElementById("clearSearchBtn"); + if (mainClearSearchBtn) { + if (query.trim()) { + mainClearSearchBtn.style.display = "block"; + } else { + mainClearSearchBtn.style.display = "none"; + } + } + updateFilterIndicator(); + }, 300); + }); + + // Обработчик клика на кнопку очистки поиска + clearSearchBtn.addEventListener("click", function () { + searchInput.value = ""; + this.style.display = "none"; + searchQuery = ""; + searchResults = []; + // Обновляем основное поле поиска + const mainSearchInput = document.getElementById("searchInput"); + if (mainSearchInput) { + mainSearchInput.value = ""; + } + const mainClearSearchBtn = document.getElementById("clearSearchBtn"); + if (mainClearSearchBtn) { + mainClearSearchBtn.style.display = "none"; + } + renderNotes(allNotes); + updateFilterIndicator(); + }); + + // Обработчик клавиши Escape для очистки поиска + searchInput.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + this.value = ""; + clearSearchBtn.style.display = "none"; + searchQuery = ""; + searchResults = []; + // Обновляем основное поле поиска + const mainSearchInput = document.getElementById("searchInput"); + if (mainSearchInput) { + mainSearchInput.value = ""; + } + const mainClearSearchBtn = document.getElementById("clearSearchBtn"); + if (mainClearSearchBtn) { + mainClearSearchBtn.style.display = "none"; + } + renderNotes(allNotes); + updateFilterIndicator(); + } + }); +} diff --git a/public/notes.html b/public/notes.html index 662cca0..d27cc49 100644 --- a/public/notes.html +++ b/public/notes.html @@ -8,6 +8,76 @@ + +
+ +
+ + +
+ + +
+ + +
+
diff --git a/public/style.css b/public/style.css index 07d87d7..4f2e94e 100644 --- a/public/style.css +++ b/public/style.css @@ -896,8 +896,111 @@ textarea:focus { color: #000; } +/* Стили для мобильного меню и слайдера */ +.mobile-menu-btn { + display: none; + position: fixed; + top: 15px; + left: 15px; + z-index: 999; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.mobile-menu-btn:hover { + background: #f8f9fa; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.mobile-menu-btn .iconify { + font-size: 24px; + color: #333; +} + +/* Мобильный слайдер */ +.mobile-sidebar { + display: none; + position: fixed; + top: 0; + left: -300px; + width: 280px; + height: 100vh; + background: white; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + overflow-y: auto; + transition: left 0.3s ease; + flex-direction: column; +} + +.mobile-sidebar.open { + left: 0; +} + +.sidebar-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + margin: 10px 10px 10px auto; + background: #f8f9fa; + border: 1px solid #ddd; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.sidebar-close-btn:hover { + background: #e9ecef; +} + +.sidebar-close-btn .iconify { + font-size: 20px; + color: #333; +} + +.sidebar-content { + padding: 10px 12px; + overflow-y: auto; + flex: 1; +} + +/* Оверлей для закрытия слайдера */ +.mobile-sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + opacity: 0; + transition: opacity 0.3s ease; +} + +.mobile-sidebar-overlay.open { + display: block; + opacity: 1; +} + /* Мобильная адаптация */ @media (max-width: 768px) { + /* Показываем мобильное меню */ + .mobile-menu-btn { + display: flex; + align-items: center; + justify-content: center; + } + /* Скрываем левый блок с календарем, поиском и тегами */ .container-leftside { display: none !important; @@ -917,6 +1020,7 @@ textarea:focus { width: 100%; max-width: 600px; margin: 0 auto; + margin-top: 60px; } /* Адаптируем контейнер заметок */