Добавлена поддержка загрузки и управления файлами в заметках

- Реализована возможность загрузки файлов (PDF, DOC, XLS и др.) к заметкам с помощью нового API.
- Добавлены функции для получения, удаления и отображения прикрепленных файлов в интерфейсе заметок.
- Обновлены стили и обработчики событий для работы с файлами в режиме редактирования и создания заметок.
- Улучшена логика обработки файлов, включая проверку на дубликаты и ограничения по размеру.
This commit is contained in:
Fovway 2025-10-29 00:07:15 +07:00
parent 372cea2e92
commit 49834d4ef4
4 changed files with 1520 additions and 171 deletions

View File

@ -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 = `
<div class="file-icon">
<span class="iconify" data-icon="${icon}"></span>
</div>
<div class="file-info">
<div class="file-name">${fileName}</div>
<div class="file-size">${fileSize} MB</div>
</div>
<button class="remove-file-btn" data-index="${index}" title="Удалить файл">×</button>
`;
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 = `
<div>📎 Загрузка файлов...</div>
<div style="font-size: 12px; margin-top: 10px;">${selectedFiles.length} файл(ов)</div>
`;
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 += "</div>";
}
// Используем файлы, которые уже пришли с заметкой
const noteFiles = Array.isArray(note.files) ? note.files : [];
let filesHtml = "";
if (noteFiles.length > 0) {
filesHtml = '<div class="note-files-container">';
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 += `
<div class="note-file-item">
<a href="${file.file_path}" download="${file.original_name}" class="note-file-link" data-file-id="${file.id}">
<span class="iconify file-icon" data-icon="${icon}"></span>
<div class="file-info">
<div class="file-name">${file.original_name}</div>
<div class="file-size">${fileSize} MB</div>
</div>
</a>
</div>
`;
});
filesHtml += "</div>";
}
// Форматируем дату создания и изменения по локали устройства
let dateDisplay;
if (note.created_at) {
@ -1935,6 +2170,7 @@ async function renderNotes(notes) {
"&quot;"
)}">${parsedContent}</div>
${imagesHtml}
${filesHtml}
</div>
`;
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 = `<span>Прикрепленные файлы:</span>`;
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 = `
<div class="file-name">${file.name}</div>
<div class="file-size">${file.size}</div>
`;
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 = `
<span>Новые файлы:</span>
<button type="button" class="clear-files-btn" id="editClearFilesBtn-${noteId}">Очистить все</button>
`;
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 = `
<span class="iconify file-icon" data-icon="${icon}"></span>
<div class="file-info-edit">
<div class="file-name">${file.name}</div>
<div class="file-size">${fileSize} MB</div>
</div>
<button class="remove-file-btn" data-index="${index}">×</button>
`;
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 = `
<div>💾 Сохранение заметки...</div>
${
selectedImages.length > 0
? `<div style="font-size: 12px; margin-top: 10px;">+ ${selectedImages.length} изображений</div>`
attachmentsInfo.length > 0
? `<div style="font-size: 12px; margin-top: 10px;">+ ${attachmentsInfo.join(
", "
)}</div>`
: ""
}
`;
@ -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) {

View File

@ -351,6 +351,9 @@
>
<span class="iconify" data-icon="mdi:image-plus"></span>
</button>
<button class="btnMarkdown" id="fileBtn" title="Прикрепить файлы">
<span class="iconify" data-icon="mdi:file-plus"></span>
</button>
<button class="btnMarkdown" id="previewBtn" title="Предпросмотр">
<span class="iconify" data-icon="mdi:eye"></span>
</button>
@ -383,6 +386,15 @@
style="display: none"
/>
<!-- Скрытый input для загрузки файлов -->
<input
type="file"
id="fileInput"
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.7z"
multiple
style="display: none"
/>
<!-- Контейнер для отображения загруженных изображений -->
<div
id="imagePreviewContainer"
@ -402,6 +414,21 @@
<div id="imagePreviewList" class="image-preview-list"></div>
</div>
<!-- Контейнер для отображения прикрепленных файлов -->
<div
id="filePreviewContainer"
class="file-preview-container"
style="display: none"
>
<div class="file-preview-header">
<span>Прикрепленные файлы:</span>
<button type="button" id="clearFilesBtn" class="clear-files-btn">
Очистить все
</button>
</div>
<div id="filePreviewList" class="file-preview-list"></div>
</div>
<div class="save-button-container">
<div class="action-buttons">
<button

View File

@ -2175,6 +2175,44 @@ textarea:focus {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Выравниваем размер контейнеров в режиме редактирования под размер контейнеров создания */
.textNote .markdown-buttons--edit,
.textNote .note-preview-container,
.textNote .image-preview-container,
.textNote .file-preview-container,
.textNote .textInput,
.textNote textarea.textInput {
width: 100%;
box-sizing: border-box;
}
/* Стили для файлов в режиме редактирования - делаем такими же сжатыми как в создании */
.textNote .file-preview-container .file-preview-item {
padding: 6px 10px;
min-height: 40px;
}
.textNote .file-preview-container .file-preview-item .file-icon {
font-size: 24px;
}
.textNote .file-preview-container .file-preview-item .file-name {
font-size: 14px;
line-height: 1.2;
}
.textNote .file-preview-container .file-preview-item .file-size {
font-size: 11px;
margin-top: 1px;
line-height: 1.2;
}
.textNote .file-preview-container .file-preview-item .remove-file-btn {
width: 24px;
height: 24px;
font-size: 14px;
}
.note-preview-content h1,
.note-preview-content h2,
.note-preview-content h3,
@ -2576,6 +2614,64 @@ textarea:focus {
position: relative;
}
.note-files-container {
margin: 10px 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.note-file-item {
display: flex;
align-items: center;
}
.note-file-link {
display: flex;
align-items: center;
padding: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
text-decoration: none;
color: inherit;
gap: 12px;
width: 100%;
transition: all 0.2s;
}
.note-file-link:hover {
background: #e9ecef;
border-color: var(--accent-color, #007bff);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.note-file-link .file-icon {
font-size: 32px;
color: var(--accent-color, #007bff);
flex-shrink: 0;
}
.note-file-link .file-info {
flex: 1;
min-width: 0;
}
.note-file-link .file-name {
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-file-link .file-size {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.note-image-item .remove-note-image-btn {
position: absolute;
top: 5px;
@ -3372,3 +3468,229 @@ textarea:focus {
background: linear-gradient(45deg, #d4edda, #c3e6cb);
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.35);
}
/* Стили для файлов */
.file-preview-container {
margin: 15px 0;
padding: 15px;
background: #f0f8ff;
border: 2px dashed #b0d4f1;
border-radius: 8px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.file-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-weight: 600;
color: #495057;
}
.file-preview-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-preview-item {
display: flex;
align-items: center;
padding: 6px 10px;
background: white;
border-radius: 6px;
border: 1px solid #dee2e6;
gap: 12px;
min-height: 40px;
}
.file-preview-item .file-icon {
font-size: 24px;
color: var(--accent-color, #007bff);
flex-shrink: 0;
}
.file-preview-item .file-info {
flex: 1;
min-width: 0;
}
.file-preview-item .file-info-edit {
flex: 1;
min-width: 0;
}
.file-preview-item .file-name {
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 1.2;
}
.file-preview-item .file-size {
font-size: 11px;
color: #666;
margin-top: 1px;
line-height: 1.2;
}
.file-info-edit .file-name {
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 1.2;
}
.file-info-edit .file-size {
font-size: 11px;
color: #666;
margin-top: 1px;
line-height: 1.2;
}
.file-preview-item .remove-file-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.file-preview-item .remove-file-btn:hover {
background: #c82333;
}
.clear-files-btn {
background: #dc3545;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s ease;
}
.clear-files-btn:hover {
background: #c82333;
}
/* Темная тема для файлов */
[data-theme="dark"] .file-preview-container {
background: #2a3a4a;
border-color: #4a5a6a;
}
[data-theme="dark"] .file-preview-header {
color: var(--text-primary);
}
[data-theme="dark"] .file-preview-item {
background: var(--bg-secondary);
border-color: var(--border-primary);
}
[data-theme="dark"] .file-preview-item .file-name {
color: var(--text-primary);
font-size: 14px;
line-height: 1.2;
}
[data-theme="dark"] .file-preview-item .file-size {
color: var(--text-secondary);
font-size: 11px;
margin-top: 1px;
line-height: 1.2;
}
[data-theme="dark"] .file-info-edit .file-name {
color: var(--text-primary);
font-size: 14px;
line-height: 1.2;
}
[data-theme="dark"] .file-info-edit .file-size {
color: var(--text-secondary);
font-size: 11px;
margin-top: 1px;
line-height: 1.2;
}
/* Темная тема для файлов в режиме редактирования */
[data-theme="dark"]
.textNote
.file-preview-container
.file-preview-item
.file-name {
color: var(--text-primary);
font-size: 14px;
line-height: 1.2;
}
[data-theme="dark"]
.textNote
.file-preview-container
.file-preview-item
.file-size {
color: var(--text-secondary);
font-size: 11px;
margin-top: 1px;
line-height: 1.2;
}
[data-theme="dark"] .note-file-link {
background: var(--bg-tertiary);
border-color: var(--border-primary);
color: var(--text-primary);
}
[data-theme="dark"] .note-file-link:hover {
background: var(--bg-hover);
border-color: var(--accent-color);
}
[data-theme="dark"] .note-file-link .file-name {
color: var(--text-primary);
}
[data-theme="dark"] .note-file-link .file-size {
color: var(--text-secondary);
}
/* Темная тема для изображений */
[data-theme="dark"] .image-preview-container {
background: #2a3a4a;
border-color: #4a5a6a;
}
[data-theme="dark"] .image-preview-header {
color: var(--text-primary);
}
[data-theme="dark"] .image-preview-item {
background: var(--bg-secondary);
border-color: var(--border-primary);
}
[data-theme="dark"] .image-preview-item .image-info {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
[data-theme="dark"] .note-images-container {
background: transparent;
}
[data-theme="dark"] .note-image-item {
border-color: var(--border-primary);
}
[data-theme="dark"] .note-image {
border-color: var(--border-primary);
}
[data-theme="dark"] .note-image:hover::after {
background: rgba(0, 0, 0, 0.7);
}

797
server.js
View File

@ -102,6 +102,58 @@ const uploadNoteImages = multer({
},
});
// Настройка multer для загрузки файлов заметок
const noteFileStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(
null,
"note-file-" +
req.session.userId +
"-" +
uniqueSuffix +
path.extname(file.originalname)
);
},
});
const uploadNoteFiles = multer({
storage: noteFileStorage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB максимум для файлов заметок
fileFilter: function (req, file, cb) {
// Разрешенные типы файлов
const allowedMimes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/plain",
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/x-7z-compressed",
];
const allowedExtensions = /pdf|doc|docx|xls|xlsx|txt|zip|rar|7z/;
const extname = allowedExtensions.test(
path.extname(file.originalname).toLowerCase()
);
if (allowedMimes.includes(file.mimetype) || extname) {
return cb(null, true);
}
cb(
new Error(
"Только файлы форматов pdf, doc, docx, xls, xlsx, txt, zip, rar, 7z разрешены!"
)
);
},
});
// Middleware для безопасности
app.use(
helmet({
@ -217,6 +269,20 @@ function createTables() {
)
`;
const createNoteFilesTable = `
CREATE TABLE IF NOT EXISTS note_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
)
`;
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -247,6 +313,14 @@ function createTables() {
}
});
db.run(createNoteFilesTable, (err) => {
if (err) {
console.error("Ошибка создания таблицы файлов заметок:", err.message);
} else {
console.log("Таблица файлов заметок готова");
}
});
db.run(createUsersTable, (err) => {
if (err) {
console.error("Ошибка создания таблицы пользователей:", err.message);
@ -268,6 +342,7 @@ function createIndexes() {
"CREATE INDEX IF NOT EXISTS idx_notes_is_archived ON notes(is_archived)",
"CREATE INDEX IF NOT EXISTS idx_notes_pinned_at ON notes(pinned_at)",
"CREATE INDEX IF NOT EXISTS idx_note_images_note_id ON note_images(note_id)",
"CREATE INDEX IF NOT EXISTS idx_note_files_note_id ON note_files(note_id)",
"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)",
@ -866,9 +941,9 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
DISTINCT json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
@ -877,10 +952,25 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
) FILTER (WHERE ni.id IS NOT NULL)
END as images,
CASE
WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]'
ELSE json_group_array(
DISTINCT json_object(
'id', nf.id,
'filename', nf.filename,
'original_name', nf.original_name,
'file_path', nf.file_path,
'file_size', nf.file_size,
'mime_type', nf.mime_type,
'created_at', nf.created_at
)
) FILTER (WHERE nf.id IS NOT NULL)
END as files
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
LEFT JOIN note_files nf ON n.id = nf.note_id
${whereClause}
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.pinned_at DESC, n.created_at DESC
@ -892,25 +982,26 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
// Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files),
}));
res.json(notesWithImages);
res.json(notesWithImagesAndFiles);
});
});
// API для получения всех заметок с изображениями (исключая архивные)
// API для получения всех заметок с изображениями и файлами (исключая архивные)
app.get("/api/notes", requireApiAuth, (req, res) => {
const sql = `
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
DISTINCT json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
@ -919,10 +1010,25 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
) FILTER (WHERE ni.id IS NOT NULL)
END as images,
CASE
WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]'
ELSE json_group_array(
DISTINCT json_object(
'id', nf.id,
'filename', nf.filename,
'original_name', nf.original_name,
'file_path', nf.file_path,
'file_size', nf.file_size,
'mime_type', nf.mime_type,
'created_at', nf.created_at
)
) FILTER (WHERE nf.id IS NOT NULL)
END as files
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
LEFT JOIN note_files nf ON n.id = nf.note_id
WHERE n.user_id = ? AND n.is_archived = 0
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.pinned_at DESC, n.created_at DESC
@ -934,13 +1040,14 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
// Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files),
}));
res.json(notesWithImages);
res.json(notesWithImagesAndFiles);
});
});
@ -1063,35 +1170,59 @@ app.delete("/api/notes/:id", requireApiAuth, (req, res) => {
});
}
// Удаляем записи об изображениях из БД
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
// Удаляем файлы заметки
const getFilesSql = "SELECT file_path FROM note_files WHERE note_id = ?";
db.all(getFilesSql, [id], (err, files) => {
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: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Заметка не найдена" });
console.error("Ошибка получения файлов:", err.message);
} else {
// Удаляем файлы
files.forEach((file) => {
const filePath = path.join(__dirname, "public", file.file_path);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});
}
// Логируем удаление заметки
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete",
`Удалена заметка #${id}`,
clientIP
);
// Удаляем записи об изображениях из БД
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
});
res.json({ message: "Заметка удалена" });
// Удаляем записи о файлах из БД
const deleteFilesSql = "DELETE FROM note_files WHERE note_id = ?";
db.run(deleteFilesSql, [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: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Заметка не найдена" });
}
// Логируем удаление заметки
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete",
`Удалена заметка #${id}`,
clientIP
);
res.json({ message: "Заметка удалена" });
});
});
});
});
@ -1280,6 +1411,189 @@ app.delete("/api/notes/:noteId/images/:imageId", requireApiAuth, (req, res) => {
});
});
// API для загрузки файлов к заметке
app.post(
"/api/notes/:id/files",
requireAuth,
uploadNoteFiles.array("files", 10), // Максимум 10 файлов
(req, res) => {
const { id } = req.params;
const files = req.files;
if (!files || files.length === 0) {
return res.status(400).json({ error: "Файлы не загружены" });
}
// Проверяем, что заметка принадлежит текущему пользователю
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 insertSql = `
INSERT INTO note_files (note_id, filename, original_name, file_path, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?)
`;
const uploadedFiles = [];
let completed = 0;
files.forEach((file) => {
const filePath = "/uploads/" + file.filename;
const params = [
id,
file.filename,
file.originalname,
filePath,
file.size,
file.mimetype,
];
db.run(insertSql, params, function (err) {
if (err) {
console.error("Ошибка сохранения файла:", err.message);
// Удаляем файл, если не удалось сохранить в БД
const fileFullPath = path.join(__dirname, "public", filePath);
if (fs.existsSync(fileFullPath)) {
fs.unlinkSync(fileFullPath);
}
} else {
uploadedFiles.push({
id: this.lastID,
filename: file.filename,
original_name: file.originalname,
file_path: filePath,
file_size: file.size,
mime_type: file.mimetype,
});
}
completed++;
if (completed === files.length) {
if (uploadedFiles.length === 0) {
return res
.status(500)
.json({ error: "Не удалось загрузить файлы" });
}
res.json({
success: true,
message: `Загружено ${uploadedFiles.length} файлов`,
files: uploadedFiles,
});
}
});
});
});
}
);
// API для получения файлов заметки
app.get("/api/notes/:id/files", requireApiAuth, (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 getFilesSql = `
SELECT id, filename, original_name, file_path, file_size, mime_type, created_at
FROM note_files
WHERE note_id = ?
ORDER BY created_at ASC
`;
db.all(getFilesSql, [id], (err, files) => {
if (err) {
console.error("Ошибка получения файлов:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json(files);
});
});
});
// API для удаления файла заметки
app.delete("/api/notes/:noteId/files/:fileId", requireApiAuth, (req, res) => {
const { noteId, fileId } = req.params;
// Проверяем, что заметка принадлежит текущему пользователю
const checkSql = "SELECT user_id FROM notes WHERE id = ?";
db.get(checkSql, [noteId], (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 getFileSql =
"SELECT file_path FROM note_files WHERE id = ? AND note_id = ?";
db.get(getFileSql, [fileId, noteId], (err, file) => {
if (err) {
console.error("Ошибка получения файла:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (!file) {
return res.status(404).json({ error: "Файл не найден" });
}
// Удаляем файл
const filePath = path.join(__dirname, "public", file.file_path);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Удаляем запись из БД
const deleteSql = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
db.run(deleteSql, [fileId, noteId], function (err) {
if (err) {
console.error("Ошибка удаления файла:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (this.changes === 0) {
return res.status(404).json({ error: "Файл не найден" });
}
res.json({ success: true, message: "Файл удален" });
});
});
});
});
// Страница личного кабинета
app.get("/profile", requireAuth, (req, res) => {
// Получаем цвет пользователя для предотвращения FOUC
@ -1648,9 +1962,9 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
SELECT
n.*,
CASE
WHEN COUNT(ni.id) = 0 THEN '[]'
WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]'
ELSE json_group_array(
json_object(
DISTINCT json_object(
'id', ni.id,
'filename', ni.filename,
'original_name', ni.original_name,
@ -1659,10 +1973,25 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
'mime_type', ni.mime_type,
'created_at', ni.created_at
)
)
END as images
) FILTER (WHERE ni.id IS NOT NULL)
END as images,
CASE
WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]'
ELSE json_group_array(
DISTINCT json_object(
'id', nf.id,
'filename', nf.filename,
'original_name', nf.original_name,
'file_path', nf.file_path,
'file_size', nf.file_size,
'mime_type', nf.mime_type,
'created_at', nf.created_at
)
) FILTER (WHERE nf.id IS NOT NULL)
END as files
FROM notes n
LEFT JOIN note_images ni ON n.id = ni.note_id
LEFT JOIN note_files nf ON n.id = nf.note_id
WHERE n.user_id = ? AND n.is_archived = 1
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.pinned_at DESC, n.created_at DESC
@ -1674,13 +2003,14 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
return res.status(500).json({ error: "Ошибка сервера" });
}
// Парсим JSON строки изображений
const notesWithImages = rows.map((row) => ({
// Парсим JSON строки изображений и файлов
const notesWithImagesAndFiles = rows.map((row) => ({
...row,
images: row.images === "[]" ? [] : JSON.parse(row.images),
files: row.files === "[]" ? [] : JSON.parse(row.files),
}));
res.json(notesWithImages);
res.json(notesWithImagesAndFiles);
});
});
@ -1750,37 +2080,65 @@ app.delete("/api/notes/archived/all", requireApiAuth, async (req, res) => {
});
}
// Удаляем записи об изображениях
const deleteImagesSql = `DELETE FROM note_images WHERE note_id IN (${placeholders})`;
db.run(deleteImagesSql, noteIds, (err) => {
// Получаем и удаляем файлы заметок
const getFilesSql = `SELECT file_path FROM note_files WHERE note_id IN (${placeholders})`;
db.all(getFilesSql, noteIds, (err, files) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
console.error("Ошибка получения файлов:", err.message);
} else {
// Удаляем файлы
files.forEach((file) => {
const filePath = path.join(__dirname, "public", file.file_path);
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch (err) {
console.error("Ошибка удаления файла:", err);
}
}
});
}
// Удаляем сами заметки
const deleteNotesSql =
"DELETE FROM notes WHERE user_id = ? AND is_archived = 1";
db.run(deleteNotesSql, [req.session.userId], function (err) {
// Удаляем записи об изображениях
const deleteImagesSql = `DELETE FROM note_images WHERE note_id IN (${placeholders})`;
db.run(deleteImagesSql, noteIds, (err) => {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
console.error("Ошибка удаления изображений:", err.message);
}
const deletedCount = this.changes;
// Удаляем записи о файлах
const deleteFilesSql = `DELETE FROM note_files WHERE note_id IN (${placeholders})`;
db.run(deleteFilesSql, noteIds, (err) => {
if (err) {
console.error("Ошибка удаления файлов:", err.message);
}
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Удалены все архивные заметки (${deletedCount} шт.)`,
clientIP
);
// Удаляем сами заметки
const deleteNotesSql =
"DELETE FROM notes WHERE user_id = ? AND is_archived = 1";
db.run(deleteNotesSql, [req.session.userId], function (err) {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
res.json({
success: true,
message: `Удалено ${deletedCount} архивных заметок`,
deletedCount,
const deletedCount = this.changes;
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Удалены все архивные заметки (${deletedCount} шт.)`,
clientIP
);
res.json({
success: true,
message: `Удалено ${deletedCount} архивных заметок`,
deletedCount,
});
});
});
});
});
@ -1831,32 +2189,55 @@ app.delete("/api/notes/archived/:id", requireApiAuth, (req, res) => {
});
}
// Удаляем записи об изображениях
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
// Удаляем файлы заметки
const getFilesSql = "SELECT file_path FROM note_files WHERE note_id = ?";
db.all(getFilesSql, [id], (err, files) => {
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: "Ошибка сервера" });
console.error("Ошибка получения файлов:", err.message);
} else {
files.forEach((file) => {
const filePath = path.join(__dirname, "public", file.file_path);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});
}
// Логируем действие
const clientIP = getClientIP(req);
logAction(
req.session.userId,
"note_delete_permanent",
`Заметка #${id} окончательно удалена из архива`,
clientIP
);
// Удаляем записи об изображениях
const deleteImagesSql = "DELETE FROM note_images WHERE note_id = ?";
db.run(deleteImagesSql, [id], (err) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
}
});
res.json({ success: true, message: "Заметка удалена окончательно" });
// Удаляем записи о файлах
const deleteFilesSql = "DELETE FROM note_files WHERE note_id = ?";
db.run(deleteFilesSql, [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: "Заметка удалена окончательно" });
});
});
});
});
@ -2214,7 +2595,7 @@ app.delete("/api/user/delete-account", requireApiAuth, async (req, res) => {
return res.status(401).json({ error: "Неверный пароль" });
}
// Получаем аватарку пользователя перед удалением
// Получаем аватарку и все файлы пользователя перед удалением
const getAvatarSql = "SELECT avatar FROM users WHERE id = ?";
db.get(getAvatarSql, [userId], (err, userData) => {
if (err) {
@ -2222,90 +2603,180 @@ app.delete("/api/user/delete-account", requireApiAuth, async (req, res) => {
return res.status(500).json({ error: "Ошибка получения аватарки" });
}
// Удаляем файл аватарки, если он существует
if (userData && userData.avatar) {
const avatarPath = path.join(
__dirname,
"public",
"uploads",
userData.avatar
);
fs.unlink(avatarPath, (err) => {
if (err && err.code !== "ENOENT") {
console.error("Ошибка удаления файла аватарки:", err.message);
}
});
}
// Получаем все изображения заметок пользователя
const getImagesSql = `
SELECT ni.file_path
FROM note_images ni
JOIN notes n ON ni.note_id = n.id
WHERE n.user_id = ?
`;
db.all(getImagesSql, [userId], (err, images) => {
if (err) {
console.error(
"Ошибка получения изображений пользователя:",
err.message
);
}
// Начинаем транзакцию для удаления всех данных пользователя
db.serialize(() => {
db.run("BEGIN TRANSACTION");
// Удаляем все изображения заметок пользователя (через JOIN с notes)
const deleteImagesSql = `
DELETE FROM note_images
WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)
// Получаем все файлы заметок пользователя
const getFilesSql = `
SELECT nf.file_path
FROM note_files nf
JOIN notes n ON nf.note_id = n.id
WHERE n.user_id = ?
`;
db.run(deleteImagesSql, [userId], (err) => {
db.all(getFilesSql, [userId], (err, files) => {
if (err) {
console.error("Ошибка удаления изображений:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления изображений" });
console.error(
"Ошибка получения файлов пользователя:",
err.message
);
}
});
// Удаляем все заметки пользователя (CASCADE удалит связанные изображения)
db.run("DELETE FROM notes WHERE user_id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
db.run("ROLLBACK");
return res.status(500).json({ error: "Ошибка удаления заметок" });
}
});
// Удаляем логи пользователя
db.run(
"DELETE FROM action_logs WHERE user_id = ?",
[userId],
(err) => {
if (err) {
console.error("Ошибка удаления логов:", err.message);
db.run("ROLLBACK");
return res.status(500).json({ error: "Ошибка удаления логов" });
// Удаляем файл аватарки
if (userData && userData.avatar) {
const avatarPath = path.join(
__dirname,
"public",
userData.avatar
);
if (fs.existsSync(avatarPath)) {
try {
fs.unlinkSync(avatarPath);
} catch (err) {
console.error("Ошибка удаления файла аватарки:", err.message);
}
}
}
);
// Удаляем пользователя из базы данных
db.run("DELETE FROM users WHERE id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления пользователя:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления пользователя" });
// Удаляем все изображения
if (images) {
images.forEach((image) => {
const imagePath = path.join(
__dirname,
"public",
image.file_path
);
if (fs.existsSync(imagePath)) {
try {
fs.unlinkSync(imagePath);
} catch (err) {
console.error("Ошибка удаления изображения:", err.message);
}
}
});
}
// Подтверждаем транзакцию
db.run("COMMIT", (err) => {
if (err) {
console.error("Ошибка подтверждения транзакции:", err.message);
return res
.status(500)
.json({ error: "Ошибка подтверждения транзакции" });
}
// Удаляем все файлы
if (files) {
files.forEach((file) => {
const filePath = path.join(__dirname, "public", file.file_path);
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch (err) {
console.error("Ошибка удаления файла:", err.message);
}
}
});
}
// Уничтожаем сессию
req.session.destroy((err) => {
// Начинаем транзакцию для удаления всех данных из БД
db.serialize(() => {
db.run("BEGIN TRANSACTION");
// Удаляем все изображения заметок пользователя
const deleteImagesSql = `
DELETE FROM note_images
WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)
`;
db.run(deleteImagesSql, [userId], (err) => {
if (err) {
console.error("Ошибка уничтожения сессии:", err.message);
console.error(
"Ошибка удаления изображений из БД:",
err.message
);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления изображений" });
}
});
// Удаляем все файлы заметок пользователя
const deleteFilesSql = `
DELETE FROM note_files
WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)
`;
db.run(deleteFilesSql, [userId], (err) => {
if (err) {
console.error("Ошибка удаления файлов из БД:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления файлов" });
}
});
// Удаляем все заметки пользователя
db.run("DELETE FROM notes WHERE user_id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления заметок:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления заметок" });
}
});
// Удаляем логи пользователя
db.run(
"DELETE FROM action_logs WHERE user_id = ?",
[userId],
(err) => {
if (err) {
console.error("Ошибка удаления логов:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления логов" });
}
}
);
// Удаляем пользователя из базы данных
db.run("DELETE FROM users WHERE id = ?", [userId], (err) => {
if (err) {
console.error("Ошибка удаления пользователя:", err.message);
db.run("ROLLBACK");
return res
.status(500)
.json({ error: "Ошибка удаления пользователя" });
}
res.json({
message: "Аккаунт успешно удален",
success: true,
// Подтверждаем транзакцию
db.run("COMMIT", (err) => {
if (err) {
console.error(
"Ошибка подтверждения транзакции:",
err.message
);
return res
.status(500)
.json({ error: "Ошибка подтверждения транзакции" });
}
// Уничтожаем сессию
req.session.destroy((err) => {
if (err) {
console.error("Ошибка уничтожения сессии:", err.message);
}
res.json({
message: "Аккаунт успешно удален",
success: true,
});
});
});
});
});