Добавлен новый API для объединения заметок с использованием AI, включая валидацию входных данных и обработку ошибок. Обновлены компоненты для поддержки выбора заметок и интеграции с модальным окном для объединения. Обновлены стили и логика в компоненте NotesPage для улучшения пользовательского интерфейса и взаимодействия с заметками.
This commit is contained in:
parent
d023e20862
commit
300e881245
162
backend/public/assets/index-vvy_XuzQ.js
Normal file
162
backend/public/assets/index-vvy_XuzQ.js
Normal file
File diff suppressed because one or more lines are too long
@ -158,7 +158,7 @@
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script type="module" crossorigin src="/assets/index-B61qRIc-.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-vvy_XuzQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
|
||||
@ -1 +1 @@
|
||||
if(!self.define){let e,n={};const i=(i,c)=>(i=new URL(i+".js",c).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(c,s)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(n[o])return;let a={};const r=e=>i(e,o),d={module:{uri:o},exports:a,require:r};n[o]=Promise.all(c.map(e=>d[e]||r(e))).then(e=>(s(...e),a))}}define(["./workbox-40c80ae4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-B61qRIc-.js",revision:"96888e49126c254a0b6fb7a9428bddb6"},{url:"assets/index-DK8OUj6L.css",revision:"b1e2c4e8724be2f2bcee585338910e99"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"ac16c70d736d7f92715a884f73979e8e"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api/,/^\/uploads/]})),e.registerRoute(({request:e})=>"navigate"===e.mode,new e.CacheFirst({cacheName:"pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\.html$/,new e.CacheFirst({cacheName:"html-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/api\./,new e.NetworkFirst({cacheName:"api-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:3600})]}),"GET"),e.registerRoute(/\/api\//,new e.NetworkFirst({cacheName:"api-cache-local",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/uploads\//,new e.CacheFirst({cacheName:"uploads-cache",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/,new e.CacheFirst({cacheName:"images-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")});
|
||||
if(!self.define){let e,n={};const i=(i,c)=>(i=new URL(i+".js",c).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(c,s)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(n[o])return;let a={};const r=e=>i(e,o),d={module:{uri:o},exports:a,require:r};n[o]=Promise.all(c.map(e=>d[e]||r(e))).then(e=>(s(...e),a))}}define(["./workbox-40c80ae4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-DK8OUj6L.css",revision:"b1e2c4e8724be2f2bcee585338910e99"},{url:"assets/index-vvy_XuzQ.js",revision:"054024cdea842a8b5c1681d0e44680fa"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"10119735705f0553541d8ce5fe13c02e"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api/,/^\/uploads/]})),e.registerRoute(({request:e})=>"navigate"===e.mode,new e.CacheFirst({cacheName:"pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\.html$/,new e.CacheFirst({cacheName:"html-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/api\./,new e.NetworkFirst({cacheName:"api-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:3600})]}),"GET"),e.registerRoute(/\/api\//,new e.NetworkFirst({cacheName:"api-cache-local",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/uploads\//,new e.CacheFirst({cacheName:"uploads-cache",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/,new e.CacheFirst({cacheName:"images-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")});
|
||||
|
||||
@ -2546,6 +2546,151 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// API для объединения заметок через AI
|
||||
app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
|
||||
const { notes } = req.body;
|
||||
|
||||
if (!notes || !Array.isArray(notes) || notes.length < 2) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Необходимо передать минимум 2 заметки" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем AI настройки пользователя
|
||||
const getSettingsSql =
|
||||
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
|
||||
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения AI настроек:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (
|
||||
!settings ||
|
||||
!settings.openai_api_key ||
|
||||
!settings.openai_base_url ||
|
||||
!settings.openai_model
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Настройте AI настройки в параметрах" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Парсим URL
|
||||
const url = new URL(settings.openai_base_url);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const hostname = url.hostname;
|
||||
const port = url.port || (isHttps ? 443 : 80);
|
||||
// Формируем путь, избегая дублирования
|
||||
let path = url.pathname || "";
|
||||
if (!path.endsWith("/chat/completions")) {
|
||||
path = path.endsWith("/")
|
||||
? path + "chat/completions"
|
||||
: path + "/chat/completions";
|
||||
}
|
||||
|
||||
// Формируем текст со всеми заметками
|
||||
const notesText = notes
|
||||
.map((note, index) => `=== Заметка ${index + 1} ===\n${note}`)
|
||||
.join("\n\n");
|
||||
|
||||
// Подготавливаем данные для запроса
|
||||
const requestData = JSON.stringify({
|
||||
model: settings.openai_model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"Ты помощник для объединения заметок. Твоя задача:\n1. Объедини следующие заметки в одну связную заметку\n2. Сохрани всю важную информацию из каждой заметки\n3. Структурируй текст логично, используй markdown форматирование (заголовки, списки, выделения)\n4. Удали дубликаты информации\n5. Если заметки на разные темы, раздели их по разделам с заголовками\n6. Сохрани даты и временные метки, если они важны\nВерни только готовый текст объединенной заметки без дополнительных пояснений.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: notesText,
|
||||
},
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 4000,
|
||||
});
|
||||
|
||||
// Выполняем HTTP запрос
|
||||
const mergedText = await new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: hostname,
|
||||
port: port,
|
||||
path: path,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${settings.openai_api_key}`,
|
||||
"Content-Length": Buffer.byteLength(requestData),
|
||||
},
|
||||
};
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
const req = client.request(options, (res) => {
|
||||
let data = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const responseData = JSON.parse(data);
|
||||
const mergedText =
|
||||
responseData.choices[0]?.message?.content || "";
|
||||
resolve(mergedText);
|
||||
} catch (err) {
|
||||
console.error("Ошибка парсинга ответа:", err);
|
||||
reject(new Error("Ошибка обработки ответа от AI"));
|
||||
}
|
||||
} else {
|
||||
console.error("Ошибка OpenAI API:", res.statusCode, data);
|
||||
reject(
|
||||
new Error(
|
||||
`Ошибка OpenAI API: ${res.statusCode} - ${data.substring(
|
||||
0,
|
||||
100
|
||||
)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
console.error("Ошибка запроса к OpenAI:", error);
|
||||
reject(new Error("Ошибка подключения к OpenAI API"));
|
||||
});
|
||||
|
||||
req.write(requestData);
|
||||
req.end();
|
||||
});
|
||||
|
||||
// Логируем использование AI
|
||||
logAction(
|
||||
req.session.userId,
|
||||
"ai_merge",
|
||||
`Объединено ${notes.length} заметок через AI`
|
||||
);
|
||||
|
||||
res.json({ success: true, mergedText });
|
||||
} catch (error) {
|
||||
console.error("Ошибка вызова OpenAI API:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: error.message || "Ошибка подключения к OpenAI API" });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка объединения заметок:", error);
|
||||
res.status(500).json({ error: "Ошибка объединения заметок" });
|
||||
}
|
||||
});
|
||||
|
||||
// Выход
|
||||
app.post("/logout", (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
@ -67,7 +67,7 @@ if (!self.define) {
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
@ -81,13 +81,36 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.spl6nc4dqkg"
|
||||
}], {});
|
||||
"url": "/index.html",
|
||||
"revision": "0.u6qfhq29adg"
|
||||
}], {
|
||||
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
||||
});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), {
|
||||
allowlist: [/^\/$/],
|
||||
denylist: [/^\/api/, /^\/uploads/]
|
||||
}));
|
||||
workbox.registerRoute(({
|
||||
request
|
||||
}) => request.mode === "navigate", new workbox.CacheFirst({
|
||||
"cacheName": "pages-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 604800
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\.html$/, new workbox.CacheFirst({
|
||||
"cacheName": "html-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 604800
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
|
||||
@ -8,4 +8,12 @@ export const aiApi = {
|
||||
);
|
||||
return data.improvedText;
|
||||
},
|
||||
|
||||
mergeNotes: async (notes: string[]): Promise<string> => {
|
||||
const { data } = await axiosClient.post<{ mergedText: string }>(
|
||||
"/ai/merge",
|
||||
{ notes }
|
||||
);
|
||||
return data.mergedText;
|
||||
},
|
||||
};
|
||||
|
||||
196
src/components/notes/MergeNotesModal.tsx
Normal file
196
src/components/notes/MergeNotesModal.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { aiApi } from "../../api/aiApi";
|
||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||
import { Note } from "../../types/note";
|
||||
import { NotePreview } from "./NotePreview";
|
||||
import { useNotification } from "../../hooks/useNotification";
|
||||
|
||||
interface MergeNotesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedNotes: Note[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedNotes,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [mergedContent, setMergedContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const isClosedRef = useRef(false);
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
// Выполняем объединение при открытии модального окна
|
||||
useEffect(() => {
|
||||
if (isOpen && selectedNotes.length >= 2) {
|
||||
isClosedRef.current = false;
|
||||
handleMerge();
|
||||
} else {
|
||||
// Сбрасываем состояние при закрытии
|
||||
setMergedContent("");
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
isClosedRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
isClosedRef.current = true;
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
setMergedContent("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
setIsLoading(true);
|
||||
setMergedContent("");
|
||||
|
||||
try {
|
||||
const notesContent = selectedNotes.map((note) => note.content);
|
||||
const merged = await aiApi.mergeNotes(notesContent);
|
||||
|
||||
// Проверяем, не была ли модалка закрыта во время запроса
|
||||
if (!isClosedRef.current) {
|
||||
setMergedContent(merged);
|
||||
}
|
||||
} catch (error) {
|
||||
// Игнорируем ошибку, если модалка была закрыта
|
||||
if (isClosedRef.current) {
|
||||
return;
|
||||
}
|
||||
console.error("Ошибка объединения заметок:", error);
|
||||
showNotification("Ошибка объединения заметок", "error");
|
||||
handleClose();
|
||||
} finally {
|
||||
if (!isClosedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!mergedContent.trim()) {
|
||||
showNotification("Нет контента для сохранения", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const now = new Date();
|
||||
const date = now.toLocaleDateString("ru-RU");
|
||||
const time = now.toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
await offlineNotesApi.create({
|
||||
content: mergedContent,
|
||||
date,
|
||||
time,
|
||||
});
|
||||
|
||||
showNotification("Объединенная заметка сохранена!", "success");
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Ошибка сохранения заметки:", error);
|
||||
showNotification("Ошибка сохранения заметки", "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal" style={{ display: "block" }} onClick={handleClose}>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ maxWidth: "800px", maxHeight: "80vh", overflow: "auto" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Объединение заметок</h3>
|
||||
<span className="modal-close" onClick={handleClose}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<div className="spinner" style={{ margin: "0 auto 20px" }}></div>
|
||||
<p>Объединяю заметки через ИИ...</p>
|
||||
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
|
||||
Выбрано заметок: {selectedNotes.length}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: "15px", color: "#666" }}>
|
||||
<p>
|
||||
Результат объединения {selectedNotes.length}{" "}
|
||||
{selectedNotes.length === 2
|
||||
? "заметок"
|
||||
: selectedNotes.length > 4
|
||||
? "заметок"
|
||||
: "заметок"}
|
||||
:
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
maxHeight: "400px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<NotePreview content={mergedContent} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isSaving || !mergedContent}
|
||||
style={{ marginRight: "10px" }}
|
||||
>
|
||||
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isLoading ? "Отменить" : "Отмена"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,6 +27,8 @@ interface NoteItemProps {
|
||||
onPin: (id: number | string) => void;
|
||||
onArchive: (id: number | string) => void;
|
||||
onReload: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (id: number | string) => void;
|
||||
}
|
||||
|
||||
export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
@ -35,6 +37,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
onPin,
|
||||
onArchive,
|
||||
onReload,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(note.content);
|
||||
@ -1132,6 +1136,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
>
|
||||
<div className="date">
|
||||
<span className="date-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelect && onSelect(note.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
cursor: "pointer",
|
||||
marginRight: "10px",
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
/>
|
||||
{formatDate()}
|
||||
{note.is_pinned ? (
|
||||
<span className="pin-indicator">
|
||||
|
||||
@ -10,7 +10,13 @@ export interface NotesListRef {
|
||||
reloadNotes: () => void;
|
||||
}
|
||||
|
||||
export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
|
||||
interface NotesListProps {
|
||||
selectedNoteIds?: (number | string)[];
|
||||
onNoteSelect?: (noteId: number | string) => void;
|
||||
}
|
||||
|
||||
export const NotesList = forwardRef<NotesListRef, NotesListProps>(
|
||||
({ selectedNoteIds = [], onNoteSelect }, ref) => {
|
||||
const notes = useAppSelector((state) => state.notes.notes);
|
||||
const userId = useAppSelector((state) => state.auth.userId);
|
||||
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||
@ -159,6 +165,8 @@ export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
|
||||
onPin={handlePin}
|
||||
onArchive={handleArchive}
|
||||
onReload={loadNotes}
|
||||
isSelected={selectedNoteIds.includes(note.id)}
|
||||
onSelect={onNoteSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Header } from "../components/layout/Header";
|
||||
import { Sidebar } from "../components/layout/Sidebar";
|
||||
import { MobileSidebar } from "../components/layout/MobileSidebar";
|
||||
import { NoteEditor } from "../components/notes/NoteEditor";
|
||||
import { NotesList, NotesListRef } from "../components/notes/NotesList";
|
||||
import { ImageModal } from "../components/common/ImageModal";
|
||||
import { MergeNotesModal } from "../components/notes/MergeNotesModal";
|
||||
import { Modal } from "../components/common/Modal";
|
||||
import { offlineNotesApi } from "../api/offlineNotesApi";
|
||||
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { useNotification } from "../hooks/useNotification";
|
||||
import {
|
||||
setSelectedDate,
|
||||
setSelectedTag,
|
||||
@ -14,9 +20,16 @@ import {
|
||||
|
||||
const NotesPage: React.FC = () => {
|
||||
const allNotes = useAppSelector((state) => state.notes.allNotes);
|
||||
const notes = useAppSelector((state) => state.notes.notes);
|
||||
const notesListRef = useRef<NotesListRef>(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [selectedNoteIds, setSelectedNoteIds] = useState<(number | string)[]>([]);
|
||||
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const { theme } = useTheme();
|
||||
const { showNotification } = useNotification();
|
||||
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||
@ -64,6 +77,71 @@ const NotesPage: React.FC = () => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleNoteSelect = (noteId: number | string) => {
|
||||
setSelectedNoteIds((prev) => {
|
||||
if (prev.includes(noteId)) {
|
||||
return prev.filter((id) => id !== noteId);
|
||||
} else {
|
||||
return [...prev, noteId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMergeNotes = () => {
|
||||
if (selectedNoteIds.length < 2) {
|
||||
return;
|
||||
}
|
||||
setIsMergeModalOpen(true);
|
||||
};
|
||||
|
||||
const handleMergeSuccess = () => {
|
||||
setSelectedNoteIds([]);
|
||||
if (notesListRef.current) {
|
||||
notesListRef.current.reloadNotes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedNoteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (selectedNoteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Удаляем все выбранные заметки
|
||||
await Promise.all(
|
||||
selectedNoteIds.map((id) => offlineNotesApi.delete(id))
|
||||
);
|
||||
|
||||
showNotification(
|
||||
`Удалено заметок: ${selectedNoteIds.length}`,
|
||||
"success"
|
||||
);
|
||||
setSelectedNoteIds([]);
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
if (notesListRef.current) {
|
||||
notesListRef.current.reloadNotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка удаления заметок:", error);
|
||||
showNotification("Ошибка удаления заметок", "error");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedNotes = notes.filter((note) =>
|
||||
selectedNoteIds.includes(note.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileSidebar
|
||||
@ -82,11 +160,152 @@ const NotesPage: React.FC = () => {
|
||||
<button onClick={handleClearFilters}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedNoteIds.length > 0 && (
|
||||
<div className="filter-indicator">
|
||||
<span className="filter-indicator-text">
|
||||
Выделено заметок: {selectedNoteIds.length}
|
||||
</span>{" "}
|
||||
<button
|
||||
onClick={() => setSelectedNoteIds([])}
|
||||
title="Снять выделение"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<NoteEditor onSave={handleNoteSave} />
|
||||
</div>
|
||||
<NotesList ref={notesListRef} />
|
||||
<NotesList
|
||||
ref={notesListRef}
|
||||
selectedNoteIds={selectedNoteIds}
|
||||
onNoteSelect={handleNoteSelect}
|
||||
/>
|
||||
</div>
|
||||
{selectedNoteIds.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{selectedNoteIds.length >= 2 && (
|
||||
<button
|
||||
onClick={handleMergeNotes}
|
||||
style={{
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor:
|
||||
theme === "dark" ? "#4CAF50" : "#2196F3",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
boxShadow:
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(76, 175, 80, 0.4)"
|
||||
: "0 4px 12px rgba(33, 150, 243, 0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "24px",
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1.1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 6px 16px rgba(76, 175, 80, 0.6)"
|
||||
: "0 6px 16px rgba(33, 150, 243, 0.6)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(76, 175, 80, 0.4)"
|
||||
: "0 4px 12px rgba(33, 150, 243, 0.4)";
|
||||
}}
|
||||
title={`Объединить ${selectedNoteIds.length} заметок`}
|
||||
>
|
||||
<Icon icon="mdi:link-variant" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme === "dark" ? "#F44336" : "#E53935",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||
opacity: isDeleting ? 0.6 : 1,
|
||||
boxShadow:
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(244, 67, 54, 0.4)"
|
||||
: "0 4px 12px rgba(229, 57, 53, 0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "24px",
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDeleting) {
|
||||
e.currentTarget.style.transform = "scale(1.1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 6px 16px rgba(244, 67, 54, 0.6)"
|
||||
: "0 6px 16px rgba(229, 57, 53, 0.6)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(244, 67, 54, 0.4)"
|
||||
: "0 4px 12px rgba(229, 57, 53, 0.4)";
|
||||
}}
|
||||
title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ImageModal />
|
||||
<MergeNotesModal
|
||||
isOpen={isMergeModalOpen}
|
||||
onClose={() => setIsMergeModalOpen(false)}
|
||||
selectedNotes={selectedNotes}
|
||||
onSuccess={handleMergeSuccess}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Удаление заметок"
|
||||
message={
|
||||
<p>
|
||||
Вы уверены, что хотите удалить{" "}
|
||||
<strong>{selectedNoteIds.length}</strong>{" "}
|
||||
{selectedNoteIds.length === 1
|
||||
? "заметку"
|
||||
: selectedNoteIds.length > 4
|
||||
? "заметок"
|
||||
: "заметки"}
|
||||
? Это действие нельзя отменить.
|
||||
</p>
|
||||
}
|
||||
confirmText={isDeleting ? "Удаление..." : "Удалить"}
|
||||
cancelText="Отмена"
|
||||
confirmType="danger"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user