diff --git a/public/app.js b/public/app.js index b31089c..648a3e5 100644 --- a/public/app.js +++ b/public/app.js @@ -318,6 +318,13 @@ const imagePreviewContainer = document.getElementById("imagePreviewContainer"); const imagePreviewList = document.getElementById("imagePreviewList"); const clearImagesBtn = document.getElementById("clearImagesBtn"); +// Элементы для загрузки файлов +const fileBtn = document.getElementById("fileBtn"); +const fileInput = document.getElementById("fileInput"); +const filePreviewContainer = document.getElementById("filePreviewContainer"); +const filePreviewList = document.getElementById("filePreviewList"); +const clearFilesBtn = document.getElementById("clearFilesBtn"); + // Элементы для предпросмотра заметки const notePreviewContainer = document.getElementById("notePreviewContainer"); const notePreviewContent = document.getElementById("notePreviewContent"); @@ -329,6 +336,7 @@ const modalClose = document.querySelector(".image-modal-close"); // Массив для хранения выбранных изображений let selectedImages = []; +let selectedFiles = []; // Флаг режима предпросмотра let isPreviewMode = false; @@ -1156,6 +1164,19 @@ imageBtn.addEventListener("touchend", function (event) { imageInput.click(); }); +// Обработчик для кнопки прикрепления файлов +fileBtn.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + fileInput.click(); +}); + +fileBtn.addEventListener("touchend", function (event) { + event.preventDefault(); + event.stopPropagation(); + fileInput.click(); +}); + // Обработчик для кнопки предпросмотра previewBtn.addEventListener("click", function () { togglePreview(); @@ -1399,6 +1420,48 @@ clearImagesBtn.addEventListener("click", function () { imageInput.value = ""; }); +// Обработчик для загрузки файлов +fileInput.addEventListener("change", function (event) { + const files = Array.from(event.target.files); + let addedCount = 0; + + files.forEach((file) => { + // Проверяем размер файла (максимум 50MB) + if (file.size > 50 * 1024 * 1024) { + showNotification( + `Файл "${file.name}" слишком большой. Максимальный размер: 50MB`, + "error" + ); + return; + } + + // Проверяем, не добавлен ли уже файл с таким именем и размером + const isDuplicate = selectedFiles.some( + (existingFile) => + existingFile.name === file.name && existingFile.size === file.size + ); + + if (!isDuplicate) { + selectedFiles.push(file); + addedCount++; + } + }); + + if (addedCount > 0) { + updateFilePreview(); + showNotification(`Добавлено файлов: ${addedCount}`, "success"); + } + + fileInput.value = ""; +}); + +// Обработчик очистки всех файлов +clearFilesBtn.addEventListener("click", function () { + selectedFiles = []; + updateFilePreview(); + fileInput.value = ""; +}); + // Обработчики модального окна modalClose.addEventListener("click", function () { imageModal.style.display = "none"; @@ -1473,6 +1536,65 @@ function updateImagePreview() { }); } +// Функция для обновления превью файлов +function updateFilePreview() { + if (selectedFiles.length === 0) { + filePreviewContainer.style.display = "none"; + return; + } + + filePreviewContainer.style.display = "block"; + filePreviewList.innerHTML = ""; + + selectedFiles.forEach((file, index) => { + const previewItem = document.createElement("div"); + previewItem.className = "file-preview-item"; + + // Форматируем размер файла + const fileSize = (file.size / 1024 / 1024).toFixed(2); + const fileName = + file.name.length > 30 ? file.name.substring(0, 30) + "..." : file.name; + + // Определяем иконку по расширению файла + const ext = file.name.split(".").pop().toLowerCase(); + let icon = "mdi:file"; + if (ext === "pdf") icon = "mdi:file-pdf"; + else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word"; + else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel"; + else if (ext === "txt") icon = "mdi:file-document"; + else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip"; + + previewItem.innerHTML = ` +
+ +
+
+
${fileName}
+
${fileSize} MB
+
+ + `; + + filePreviewList.appendChild(previewItem); + + // Обработчик удаления файла + const removeBtn = previewItem.querySelector(".remove-file-btn"); + removeBtn.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + selectedFiles.splice(index, 1); + updateFilePreview(); + }); + + removeBtn.addEventListener("touchend", function (event) { + event.preventDefault(); + event.stopPropagation(); + selectedFiles.splice(index, 1); + updateFilePreview(); + }); + }); +} + // Функция для отображения изображения в модальном окне function showImageModal(imageSrc) { console.log("showImageModal called with:", imageSrc); @@ -1576,6 +1698,70 @@ async function getNoteImages(noteId) { } } +// Функция для загрузки файлов на сервер +async function uploadFiles(noteId) { + if (selectedFiles.length === 0) { + return []; + } + + const formData = new FormData(); + selectedFiles.forEach((file) => { + formData.append("files", file); + }); + + try { + const isMobile = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ) || + (navigator.maxTouchPoints && navigator.maxTouchPoints > 2) || + window.matchMedia("(max-width: 768px)").matches; + + if (isMobile) { + const loadingDiv = document.createElement("div"); + loadingDiv.id = "mobile-file-upload-loading"; + loadingDiv.style.cssText = ` + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.8); color: white; padding: 20px; + border-radius: 10px; z-index: 10000; font-size: 16px; text-align: center; + `; + loadingDiv.innerHTML = ` +
📎 Загрузка файлов...
+
${selectedFiles.length} файл(ов)
+ `; + document.body.appendChild(loadingDiv); + } + + const response = await fetch(`/api/notes/${noteId}/files`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Ошибка загрузки файлов"); + } + + const result = await response.json(); + + const loadingDiv = document.getElementById("mobile-file-upload-loading"); + if (loadingDiv) { + loadingDiv.remove(); + } + + return result.files || []; + } catch (error) { + console.error("Ошибка загрузки файлов:", error); + + const loadingDiv = document.getElementById("mobile-file-upload-loading"); + if (loadingDiv) { + loadingDiv.remove(); + } + + showNotification(`Ошибка загрузки файлов: ${error.message}`, "error"); + return []; + } +} + // Функция для удаления изображения заметки async function deleteNoteImage(noteId, imageId) { try { @@ -1594,6 +1780,24 @@ async function deleteNoteImage(noteId, imageId) { } } +// Функция для удаления файла заметки +async function deleteNoteFile(noteId, fileId) { + try { + const response = await fetch(`/api/notes/${noteId}/files/${fileId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Ошибка удаления файла"); + } + + return true; + } catch (error) { + console.error("Ошибка удаления файла:", error); + return false; + } +} + // Функция для загрузки заметок с сервера async function loadNotes(forceReload = false) { const now = Date.now(); @@ -1881,6 +2085,37 @@ async function renderNotes(notes) { imagesHtml += ""; } + // Используем файлы, которые уже пришли с заметкой + const noteFiles = Array.isArray(note.files) ? note.files : []; + let filesHtml = ""; + + if (noteFiles.length > 0) { + filesHtml = '
'; + noteFiles.forEach((file) => { + const ext = file.original_name.split(".").pop().toLowerCase(); + let icon = "mdi:file"; + if (ext === "pdf") icon = "mdi:file-pdf"; + else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word"; + else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel"; + else if (ext === "txt") icon = "mdi:file-document"; + else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip"; + + const fileSize = (file.file_size / 1024 / 1024).toFixed(2); + filesHtml += ` +
+ + +
+
${file.original_name}
+
${fileSize} MB
+
+
+
+ `; + }); + filesHtml += "
"; + } + // Форматируем дату создания и изменения по локали устройства let dateDisplay; if (note.created_at) { @@ -1935,6 +2170,7 @@ async function renderNotes(notes) { """ )}">${parsedContent} ${imagesHtml} + ${filesHtml} `; notesList.insertAdjacentHTML("beforeend", noteHtml); @@ -2128,6 +2364,32 @@ function addNoteEventListeners() { }); } + // Получаем файлы из контейнера заметки + const filesContainer = noteContainer.querySelector( + ".note-files-container" + ); + let noteFilesList = []; + if (filesContainer) { + const fileElements = filesContainer.querySelectorAll(".note-file-item"); + fileElements.forEach((fileElement) => { + const link = fileElement.querySelector(".note-file-link"); + if (link) { + const fileId = link.dataset.fileId || null; + const fileName = + link.querySelector(".file-name")?.textContent || ""; + const fileSize = + link.querySelector(".file-size")?.textContent || ""; + const filePath = link.getAttribute("href") || ""; + noteFilesList.push({ + id: fileId, + name: fileName, + size: fileSize, + path: filePath, + }); + } + }); + } + // Разворачиваем заметку при редактировании noteContent.classList.remove("collapsed"); @@ -2142,6 +2404,11 @@ function addNoteEventListeners() { imagesContainer.style.display = "none"; } + // Скрываем контейнер с файлами заметки при редактировании + if (filesContainer) { + filesContainer.style.display = "none"; + } + // Создаем контейнер для markdown кнопок const markdownButtonsContainer = document.createElement("div"); markdownButtonsContainer.classList.add( @@ -2176,6 +2443,7 @@ function addNoteEventListeners() { tag: "- [ ] ", }, { id: "editImageBtn", icon: "mdi:image-plus", tag: "image" }, + { id: "editFileBtn", icon: "mdi:file-plus", tag: "file" }, { id: "editPreviewBtn", icon: "mdi:eye", tag: "preview" }, ]; @@ -2384,6 +2652,14 @@ function addNoteEventListeners() { editImageInput.style.display = "none"; editImageInput.id = `editImageInput-${noteId}`; + // Создаем элементы для загрузки файлов в режиме редактирования + const editFileInput = document.createElement("input"); + editFileInput.type = "file"; + editFileInput.accept = ".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.7z"; + editFileInput.multiple = true; + editFileInput.style.display = "none"; + editFileInput.id = `editFileInput-${noteId}`; + // Контейнер для существующих изображений const existingImagesContainer = document.createElement("div"); existingImagesContainer.id = `existingImagesContainer-${noteId}`; @@ -2466,6 +2742,98 @@ function addNoteEventListeners() { // Массив для хранения новых изображений в режиме редактирования const editSelectedImages = []; + // Контейнер для существующих файлов + const existingFilesContainer = document.createElement("div"); + existingFilesContainer.id = `existingFilesContainer-${noteId}`; + existingFilesContainer.classList.add("file-preview-container"); + existingFilesContainer.style.display = + noteFilesList.length > 0 ? "block" : "none"; + + const existingFilesHeader = document.createElement("div"); + existingFilesHeader.classList.add("file-preview-header"); + existingFilesHeader.innerHTML = `Прикрепленные файлы:`; + + const existingFilesList = document.createElement("div"); + existingFilesList.id = `existingFilesList-${noteId}`; + existingFilesList.classList.add("file-preview-list"); + + existingFilesContainer.appendChild(existingFilesHeader); + existingFilesContainer.appendChild(existingFilesList); + + // Массив для отслеживания удаленных файлов + const deletedFilesIds = []; + + // Отображаем существующие файлы + if (noteFilesList.length > 0) { + noteFilesList.forEach((file) => { + const previewItem = document.createElement("div"); + previewItem.className = "file-preview-item"; + previewItem.dataset.fileId = file.id; + + const ext = file.name.split(".").pop().toLowerCase(); + let icon = "mdi:file"; + if (ext === "pdf") icon = "mdi:file-pdf-box"; + else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word-box"; + else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel-box"; + else if (ext === "txt") icon = "mdi:file-document-outline"; + else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip"; + + const fileIcon = document.createElement("span"); + fileIcon.className = "iconify file-icon"; + fileIcon.setAttribute("data-icon", icon); + + const fileInfo = document.createElement("div"); + fileInfo.className = "file-info-edit"; + fileInfo.innerHTML = ` +
${file.name}
+
${file.size}
+ `; + + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-file-btn"; + removeBtn.textContent = "×"; + removeBtn.title = "Удалить файл"; + + // Обработчик удаления существующего файла + removeBtn.addEventListener("click", function () { + deletedFilesIds.push(file.id); + previewItem.remove(); + // Если больше нет файлов, скрываем контейнер + if (existingFilesList.children.length === 0) { + existingFilesContainer.style.display = "none"; + } + }); + + previewItem.appendChild(fileIcon); + previewItem.appendChild(fileInfo); + previewItem.appendChild(removeBtn); + + existingFilesList.appendChild(previewItem); + }); + } + + const editFilePreviewContainer = document.createElement("div"); + editFilePreviewContainer.id = `editFilePreviewContainer-${noteId}`; + editFilePreviewContainer.classList.add("file-preview-container"); + editFilePreviewContainer.style.display = "none"; + + const editFilePreviewHeader = document.createElement("div"); + editFilePreviewHeader.classList.add("file-preview-header"); + editFilePreviewHeader.innerHTML = ` + Новые файлы: + + `; + + const editFilePreviewList = document.createElement("div"); + editFilePreviewList.id = `editFilePreviewList-${noteId}`; + editFilePreviewList.classList.add("file-preview-list"); + + editFilePreviewContainer.appendChild(editFilePreviewHeader); + editFilePreviewContainer.appendChild(editFilePreviewList); + + // Массив для хранения новых файлов в режиме редактирования + const editSelectedFiles = []; + // Контейнер для кнопки сохранения и подсказки const saveButtonContainer = document.createElement("div"); saveButtonContainer.classList.add("save-button-container"); @@ -2545,6 +2913,50 @@ function addNoteEventListeners() { }); }; + // Функция обновления превью файлов для режима редактирования + const updateEditFilePreview = function () { + if (editSelectedFiles.length === 0) { + editFilePreviewContainer.style.display = "none"; + return; + } + + editFilePreviewContainer.style.display = "block"; + editFilePreviewList.innerHTML = ""; + + editSelectedFiles.forEach((file, index) => { + const previewItem = document.createElement("div"); + previewItem.className = "file-preview-item"; + + const ext = file.name.split(".").pop().toLowerCase(); + let icon = "mdi:file"; + if (ext === "pdf") icon = "mdi:file-pdf-box"; + else if (["doc", "docx"].includes(ext)) icon = "mdi:file-word-box"; + else if (["xls", "xlsx"].includes(ext)) icon = "mdi:file-excel-box"; + else if (ext === "txt") icon = "mdi:file-document-outline"; + else if (["zip", "rar", "7z"].includes(ext)) icon = "mdi:folder-zip"; + + const fileSize = (file.size / 1024 / 1024).toFixed(2); + + previewItem.innerHTML = ` + +
+
${file.name}
+
${fileSize} MB
+
+ + `; + + editFilePreviewList.appendChild(previewItem); + + // Обработчик удаления файла + const removeBtn = previewItem.querySelector(".remove-file-btn"); + removeBtn.addEventListener("click", function () { + editSelectedFiles.splice(index, 1); + updateEditFilePreview(); + }); + }); + }; + // Функция загрузки изображений для режима редактирования const uploadEditImages = async function (noteId) { if (editSelectedImages.length === 0) { @@ -2574,12 +2986,43 @@ function addNoteEventListeners() { } }; + // Функция загрузки файлов для режима редактирования + const uploadEditFiles = async function (noteId) { + if (editSelectedFiles.length === 0) { + return []; + } + + const formData = new FormData(); + editSelectedFiles.forEach((file) => { + formData.append("files", file); + }); + + try { + const response = await fetch(`/api/notes/${noteId}/files`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Ошибка загрузки файлов"); + } + + const result = await response.json(); + return result.files || []; + } catch (error) { + console.error("Ошибка загрузки файлов:", error); + return []; + } + }; + // Функция сохранения для редактирования const saveEditNote = async function () { if ( textarea.value.trim() !== "" || editSelectedImages.length > 0 || - deletedImagesIds.length > 0 + deletedImagesIds.length > 0 || + editSelectedFiles.length > 0 || + deletedFilesIds.length > 0 ) { try { // Сбрасываем режим предпросмотра перед сохранением @@ -2595,7 +3038,7 @@ function addNoteEventListeners() { "Content-Type": "application/json", }, body: JSON.stringify({ - content: textarea.value || " ", // Минимальный контент, если только изображения + content: textarea.value || " ", // Минимальный контент, если только изображения/файлы }), }); @@ -2608,11 +3051,21 @@ function addNoteEventListeners() { await deleteNoteImage(noteId, imageId); } + // Удаляем файлы, которые были помечены на удаление + for (const fileId of deletedFilesIds) { + await deleteNoteFile(noteId, fileId); + } + // Загружаем новые изображения, если они есть if (editSelectedImages.length > 0) { await uploadEditImages(noteId); } + // Загружаем новые файлы, если они есть + if (editSelectedFiles.length > 0) { + await uploadEditFiles(noteId); + } + // Перезагружаем заметки await loadNotes(true); } catch (error) { @@ -2630,8 +3083,9 @@ function addNoteEventListeners() { const originalMarkdown = noteContent.dataset.originalContent || ""; const hasTextChanges = textarea.value !== originalMarkdown; const hasNewImages = editSelectedImages.length > 0; + const hasNewFiles = editSelectedFiles.length > 0; - if (hasTextChanges || hasNewImages) { + if (hasTextChanges || hasNewImages || hasNewFiles) { const ok = await showConfirmModal( "Подтверждение отмены", "Отменить изменения?", @@ -2659,6 +3113,18 @@ function addNoteEventListeners() { editImageInput.value = ""; } + // Очистить выбранные новые файлы и превью + editSelectedFiles.length = 0; + if (editFilePreviewContainer) { + editFilePreviewContainer.style.display = "none"; + } + if (editFilePreviewList) { + editFilePreviewList.innerHTML = ""; + } + if (editFileInput) { + editFileInput.value = ""; + } + // Перерисовать заметки, вернув исходное состояние await loadNotes(true); }; @@ -2696,6 +3162,41 @@ function addNoteEventListeners() { editImageInput.value = ""; }); + // Обработчики для загрузки файлов в режиме редактирования + editFileInput.addEventListener("change", function (event) { + const files = Array.from(event.target.files); + const allowedExtensions = /\.(pdf|doc|docx|xls|xlsx|txt|zip|rar|7z)$/i; + + files.forEach((file) => { + if (allowedExtensions.test(file.name)) { + if (file.size <= 50 * 1024 * 1024) { + editSelectedFiles.push(file); + } else { + showNotification( + `Файл ${file.name} слишком большой (максимум 50 МБ)`, + "error" + ); + } + } else { + showNotification( + `Файл ${file.name} имеет недопустимый формат`, + "error" + ); + } + }); + updateEditFilePreview(); + }); + + // Обработчик очистки всех файлов в режиме редактирования + const editClearFilesBtn = editFilePreviewHeader.querySelector( + `#editClearFilesBtn-${noteId}` + ); + editClearFilesBtn.addEventListener("click", function () { + editSelectedFiles.length = 0; + updateEditFilePreview(); + editFileInput.value = ""; + }); + // Создаем контейнер для предпросмотра в режиме редактирования const editPreviewContainer = document.createElement("div"); editPreviewContainer.classList.add("note-preview-container"); @@ -2714,14 +3215,17 @@ function addNoteEventListeners() { // Флаг для режима предпросмотра редактирования let isEditPreviewMode = false; - // Очищаем текущий контент и вставляем markdown кнопки, textarea, элементы для изображений и контейнер с кнопкой сохранить + // Очищаем текущий контент и вставляем markdown кнопки, textarea, элементы для изображений и файлов и контейнер с кнопкой сохранить noteContent.innerHTML = ""; noteContent.appendChild(markdownButtonsContainer); noteContent.appendChild(textarea); noteContent.appendChild(editPreviewContainer); noteContent.appendChild(editImageInput); + noteContent.appendChild(editFileInput); noteContent.appendChild(existingImagesContainer); noteContent.appendChild(editImagePreviewContainer); + noteContent.appendChild(existingFilesContainer); + noteContent.appendChild(editFilePreviewContainer); noteContent.appendChild(saveButtonContainer); // Применяем авторасширение после добавления в DOM @@ -2741,6 +3245,9 @@ function addNoteEventListeners() { if (button.tag === "image") { // Для кнопки изображения открываем диалог выбора файлов editImageInput.click(); + } else if (button.tag === "file") { + // Для кнопки файлов открываем диалог выбора файлов + editFileInput.click(); } else if (button.tag === "color") { // Для кнопки цвета открываем диалог выбора цвета insertColorTagForEdit(textarea); @@ -3099,7 +3606,11 @@ function addSpoilerEventListeners() { // Функция сохранения заметки (вынесена отдельно для повторного использования) async function saveNote() { - if (noteInput.value.trim() !== "" || selectedImages.length > 0) { + if ( + noteInput.value.trim() !== "" || + selectedImages.length > 0 || + selectedFiles.length > 0 + ) { try { const { date, time } = getFormattedDateTime(); @@ -3128,11 +3639,21 @@ async function saveNote() { font-size: 16px; text-align: center; `; + const attachmentsInfo = []; + if (selectedImages.length > 0) { + attachmentsInfo.push(`${selectedImages.length} изображений`); + } + if (selectedFiles.length > 0) { + attachmentsInfo.push(`${selectedFiles.length} файлов`); + } + savingIndicator.innerHTML = `
💾 Сохранение заметки...
${ - selectedImages.length > 0 - ? `
+ ${selectedImages.length} изображений
` + attachmentsInfo.length > 0 + ? `
+ ${attachmentsInfo.join( + ", " + )}
` : "" } `; @@ -3163,17 +3684,25 @@ async function saveNote() { await uploadImages(noteId); } + // Загружаем файлы, если они есть + if (selectedFiles.length > 0) { + await uploadFiles(noteId); + } + // Удаляем индикатор сохранения if (savingIndicator) { savingIndicator.remove(); } - // Очищаем поле ввода и изображения, перезагружаем заметки + // Очищаем поле ввода, изображения и файлы, перезагружаем заметки noteInput.value = ""; noteInput.style.height = "auto"; selectedImages = []; + selectedFiles = []; updateImagePreview(); + updateFilePreview(); imageInput.value = ""; + fileInput.value = ""; // Сбрасываем режим предпросмотра, если он был активен if (isPreviewMode) { diff --git a/public/notes.html b/public/notes.html index 3b26c27..e71287c 100644 --- a/public/notes.html +++ b/public/notes.html @@ -351,6 +351,9 @@ > + @@ -383,6 +386,15 @@ style="display: none" /> + + +
+ + +