Compare commits

...

5 Commits

Author SHA1 Message Date
1d4cf84d6d npm run build 2025-11-07 16:48:24 +07:00
80946a3c4a Обновлены компоненты NoteEditor и NoteItem для улучшения отображения текста кнопок с добавлением обертки в span. Изменены стили кнопок для улучшения их внешнего вида и адаптивности, включая изменения отступов и размеров. Эти изменения способствуют более удобному взаимодействию пользователя с интерфейсом. 2025-11-07 16:45:38 +07:00
cb40f032d2 Добавлен модуль улучшения текста в NoteEditor с интеграцией нового модального окна. Реализована логика обработки ошибок и отображения улучшенного текста. Обновлены состояния для управления модальным окном и улучшением текста, что улучшает взаимодействие пользователя с заметками. 2025-11-07 16:29:00 +07:00
74ae2ead31 Добавлен новый API для генерации тегов через AI, включая валидацию входных данных и обработку ошибок. Реализованы компоненты для отображения модального окна с предложенными тегами в NoteEditor и NoteItem. Обновлены стили и логика для улучшения пользовательского интерфейса и взаимодействия с заметками. 2025-11-07 16:08:23 +07:00
772f5b1955 Добавлено новое поле ai_enabled в запросы к базе данных для получения настроек AI пользователя. Реализована проверка включения функций ИИ в API для улучшения обработки запросов. Обновлены компоненты MergeNotesModal и NotesPage для поддержки удаления оригинальных заметок. Изменены уведомления и стили для улучшения пользовательского интерфейса. 2025-11-07 15:51:35 +07:00
18 changed files with 1397 additions and 94 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -158,8 +158,8 @@
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-vvy_XuzQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
<script type="module" crossorigin src="/assets/index-Tw4PJyEU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3lVJ81m.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body>
<div id="root">

View File

@ -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} didnt 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")});
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} didnt register its module`);return e}));self.define=(c,s)=>{const a=e||("document"in self?document.currentScript.src:"")||location.href;if(n[a])return;let o={};const r=e=>i(e,a),d={module:{uri:a},exports:o,require:r};n[a]=Promise.all(c.map(e=>d[e]||r(e))).then(e=>(s(...e),o))}}define(["./workbox-40c80ae4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-C3lVJ81m.css",revision:"b7d7a06e04415f937626bf2a2fa0e494"},{url:"assets/index-Tw4PJyEU.js",revision:"258806cb29e8a6a43d2fe456136dffbf"},{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:"81dcb6a6346bcee9f6f569316322388e"},{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")});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2423,7 +2423,7 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
@ -2441,6 +2441,13 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
.json({ error: "Настройте AI настройки в параметрах" });
}
// Проверяем, включены ли функции ИИ
if (!settings.ai_enabled || settings.ai_enabled === 0) {
return res
.status(403)
.json({ error: "Функции ИИ отключены в настройках" });
}
try {
// Парсим URL
const url = new URL(settings.openai_base_url);
@ -2546,20 +2553,25 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
}
});
// API для объединения заметок через AI
app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
const { notes } = req.body;
// API для генерации тегов через AI
app.post("/api/ai/generate-tags", requireApiAuth, async (req, res) => {
const { text } = req.body;
if (!notes || !Array.isArray(notes) || notes.length < 2) {
return res
.status(400)
.json({ error: "Необходимо передать минимум 2 заметки" });
if (!text) {
return res.status(400).json({ error: "Текст обязателен" });
}
// Проверяем минимальную длину текста
if (text.trim().length < 10) {
return res.status(400).json({
error: "Текст слишком короткий для генерации тегов. Минимум 10 символов.",
});
}
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
@ -2577,6 +2589,358 @@ app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
.json({ error: "Настройте AI настройки в параметрах" });
}
// Проверяем, включены ли функции ИИ
if (!settings.ai_enabled || settings.ai_enabled === 0) {
return res
.status(403)
.json({ error: "Функции ИИ отключены в настройках" });
}
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 requestBody = {
model: settings.openai_model,
messages: [
{
role: "system",
content:
"Ты помощник для генерации тегов. Проанализируй текст и предложи 3-8 релевантных тегов на русском языке через запятую. Теги должны быть краткими (1-3 слова). Избегай общих слов типа 'текст', 'заметка', 'информация'. Верни ТОЛЬКО теги через запятую, без знаков #, без нумерации, без точек. Пример: работа, проект, задачи, дедлайн",
},
{
role: "user",
content: text.substring(0, 2000), // Ограничиваем длину текста
},
],
temperature: 0.7,
max_tokens: 200,
};
const requestData = JSON.stringify(requestBody);
console.log("Отправляем запрос к AI API:");
console.log("URL:", settings.openai_base_url);
console.log("Модель:", settings.openai_model);
console.log("Длина текста:", text.length);
console.log(
"Запрос (без ключа):",
JSON.stringify(
{
...requestBody,
messages: requestBody.messages.map((m) => ({
...m,
content:
m.content.length > 100
? m.content.substring(0, 100) + "..."
: m.content,
})),
},
null,
2
)
);
// Выполняем HTTP запрос
const tagsResponse = 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);
console.log(
"Полный ответ от AI API:",
JSON.stringify(responseData, null, 2)
);
// Проверяем различные форматы ответа
let tagsText = "";
// Стандартный формат OpenAI
if (
responseData.choices &&
responseData.choices[0] &&
responseData.choices[0].message
) {
tagsText = responseData.choices[0].message.content || "";
}
// Альтернативный формат (некоторые API)
else if (
responseData.choices &&
responseData.choices[0] &&
responseData.choices[0].text
) {
tagsText = responseData.choices[0].text || "";
}
// Прямой content в корне (некоторые API)
else if (responseData.content) {
tagsText = responseData.content || "";
}
// Прямой text в корне (некоторые API)
else if (responseData.text) {
tagsText = responseData.text || "";
}
// Пробуем найти любой text или content в ответе
else {
const responseString = JSON.stringify(responseData);
console.log(
"Пробуем найти content/text в ответе:",
responseString
);
// Если ничего не нашли, пробуем извлечь из ответа
if (
responseData.choices &&
responseData.choices.length > 0
) {
const firstChoice = responseData.choices[0];
tagsText =
firstChoice.message?.content ||
firstChoice.text ||
firstChoice.content ||
firstChoice.message?.text ||
"";
}
}
console.log("Извлеченный текст тегов:", tagsText);
console.log("Длина текста:", tagsText.length);
if (!tagsText || tagsText.trim().length === 0) {
console.error("Пустой content в ответе AI:", responseData);
console.error(
"Структура ответа:",
Object.keys(responseData)
);
if (responseData.choices) {
console.error(
"Choices структура:",
JSON.stringify(responseData.choices, null, 2)
);
}
reject(
new Error(
"ИИ вернул пустой ответ. Проверьте настройки модели и попробуйте еще раз."
)
);
return;
}
resolve(tagsText);
} catch (err) {
console.error("Ошибка парсинга ответа:", err);
console.error("Данные ответа:", data);
reject(
new Error("Ошибка обработки ответа от AI: " + err.message)
);
}
} else {
console.error("Ошибка OpenAI API:", res.statusCode, data);
let errorMessage = `Ошибка OpenAI API: ${res.statusCode}`;
try {
const errorData = JSON.parse(data);
if (errorData.error && errorData.error.message) {
errorMessage += " - " + errorData.error.message;
}
} catch (e) {
errorMessage += " - " + data.substring(0, 200);
}
reject(new Error(errorMessage));
}
});
});
req.on("error", (error) => {
console.error("Ошибка запроса к OpenAI:", error);
reject(new Error("Ошибка подключения к OpenAI API"));
});
req.write(requestData);
req.end();
});
// Логируем сырой ответ от AI для отладки
console.log("AI ответ для генерации тегов:", tagsResponse);
console.log("Тип ответа:", typeof tagsResponse);
console.log("Длина ответа:", tagsResponse ? tagsResponse.length : 0);
// Проверяем, что ответ не пустой
if (
!tagsResponse ||
typeof tagsResponse !== "string" ||
tagsResponse.trim().length === 0
) {
console.error("AI вернул пустой ответ");
return res.status(500).json({
error:
"ИИ вернул пустой ответ. Попробуйте еще раз или проверьте настройки AI.",
});
}
// Парсим теги из ответа
// Пробуем разные разделители: запятая, точка с запятой, перенос строки
let tags = [];
const cleanResponse = tagsResponse.trim();
// Сначала пробуем разделить по запятой
if (cleanResponse.includes(",")) {
tags = cleanResponse.split(",");
}
// Затем пробуем точку с запятой
else if (cleanResponse.includes(";")) {
tags = cleanResponse.split(";");
}
// Затем пробуем перенос строки
else if (cleanResponse.includes("\n")) {
tags = cleanResponse.split("\n");
}
// Если ничего не найдено, пробуем пробел (как последний вариант)
else {
tags = cleanResponse.split(/\s+/);
}
console.log("Теги до обработки:", tags);
// Обрабатываем теги
tags = tags
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
.map((tag) => {
// Убираем # в начале и конце
tag = tag.replace(/^#+\s*/, "").replace(/\s*#+$/, "");
// Убираем точки, дефисы в начале/конце если они есть
tag = tag.replace(/^[.\-\s]+|[.\-\s]+$/g, "");
// Убираем кавычки если есть
tag = tag.replace(/^["']+|["']+$/g, "");
return tag.trim();
})
.filter((tag) => {
// Фильтруем слишком короткие и слишком длинные теги
return tag.length > 0 && tag.length <= 50;
})
.filter((tag) => {
// Фильтруем общие слова, которые не являются тегами
const commonWords = [
"текст",
"заметка",
"информация",
"данные",
"файл",
"документ",
];
return !commonWords.includes(tag.toLowerCase());
})
.slice(0, 10); // Максимум 10 тегов
console.log("Распарсенные теги после обработки:", tags);
// Если тегов нет, это тоже ошибка
if (!tags || tags.length === 0) {
console.error(
"Не удалось распарсить теги из ответа AI. Исходный ответ:",
tagsResponse
);
return res.status(500).json({
error:
"ИИ вернул ответ, но не удалось извлечь теги. Попробуйте еще раз или добавьте больше текста в заметку.",
});
}
// Логируем использование AI
logAction(
req.session.userId,
"ai_generate_tags",
`Сгенерированы теги через AI: ${tags.length} тегов`
);
res.json({ success: true, tags });
} catch (error) {
console.error("Ошибка вызова OpenAI API:", error);
console.error("Стек ошибки:", error.stack);
return res.status(500).json({
error: error.message || "Ошибка подключения к OpenAI API",
details:
process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
});
} catch (error) {
console.error("Ошибка генерации тегов:", error);
console.error("Стек ошибки:", error.stack);
res.status(500).json({
error: error.message || "Ошибка генерации тегов",
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
});
// 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, ai_enabled 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 настройки в параметрах" });
}
// Проверяем, включены ли функции ИИ
if (!settings.ai_enabled || settings.ai_enabled === 0) {
return res
.status(403)
.json({ error: "Функции ИИ отключены в настройках" });
}
try {
// Парсим URL
const url = new URL(settings.openai_base_url);

View File

@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "/index.html",
"revision": "0.u6qfhq29adg"
"revision": "0.tafmhe7064g"
}], {
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
});

View File

@ -16,4 +16,12 @@ export const aiApi = {
);
return data.mergedText;
},
generateTags: async (text: string): Promise<string[]> => {
const { data } = await axiosClient.post<{ tags: string[] }>(
"/ai/generate-tags",
{ text }
);
return data.tags;
},
};

View File

@ -0,0 +1,265 @@
import React, { useState, useEffect } from "react";
import { useNotification } from "../../hooks/useNotification";
interface GenerateTagsModalProps {
isOpen: boolean;
onClose: () => void;
onSelectTags: (tags: string[]) => void;
suggestedTags: string[];
existingTags: string[];
isLoading?: boolean;
hasError?: boolean;
}
export const GenerateTagsModal: React.FC<GenerateTagsModalProps> = ({
isOpen,
onClose,
onSelectTags,
suggestedTags,
existingTags,
isLoading = false,
hasError = false,
}) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { showNotification } = useNotification();
useEffect(() => {
if (isOpen) {
// При открытии модалки, выбираем только новые теги (которых еще нет в заметке)
const newTags = suggestedTags.filter(
(tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())
);
setSelectedTags(newTags);
} else {
// При закрытии сбрасываем выбор
setSelectedTags([]);
}
}, [isOpen, suggestedTags, existingTags]);
const handleTagToggle = (tag: string) => {
setSelectedTags((prev) => {
if (prev.includes(tag)) {
return prev.filter((t) => t !== tag);
} else {
return [...prev, tag];
}
});
};
const handleSelectAll = () => {
const newTags = suggestedTags.filter(
(tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())
);
setSelectedTags(newTags);
};
const handleDeselectAll = () => {
setSelectedTags([]);
};
const handleApply = () => {
if (selectedTags.length === 0) {
showNotification("Выберите хотя бы один тег", "warning");
return;
}
onSelectTags(selectedTags);
onClose();
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
const newTags = suggestedTags.filter(
(tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())
);
const existingInSuggested = suggestedTags.filter((tag) =>
existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())
);
return (
<div className="modal" style={{ display: "block" }} onClick={onClose}>
<div
className="modal-content"
style={{ maxWidth: "600px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Выберите теги</h3>
<span className="modal-close" onClick={onClose}>
&times;
</span>
</div>
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Генерирую теги через ИИ...</p>
</div>
) : hasError ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<p style={{ color: "#dc3545", marginBottom: "10px" }}>
Не удалось сгенерировать теги
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
Произошла ошибка при генерации тегов. Проверьте настройки AI или попробуйте еще раз.
</p>
</div>
) : suggestedTags.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<p style={{ color: "#dc3545", marginBottom: "10px" }}>
Не удалось сгенерировать теги
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
ИИ не смог предложить теги для этой заметки. Попробуйте еще раз или добавьте больше текста.
</p>
</div>
) : (
<>
{newTags.length > 0 && (
<>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "15px",
}}
>
<h4 style={{ margin: 0, fontSize: "16px" }}>Предлагаемые теги:</h4>
<div style={{ display: "flex", gap: "10px" }}>
<button
className="btn-secondary"
onClick={handleSelectAll}
style={{ fontSize: "12px", padding: "5px 10px" }}
>
Выбрать все
</button>
<button
className="btn-secondary"
onClick={handleDeselectAll}
style={{ fontSize: "12px", padding: "5px 10px" }}
>
Снять все
</button>
</div>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "10px",
marginBottom: "20px",
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-secondary)",
minHeight: "60px",
}}
>
{newTags.map((tag) => (
<button
key={tag}
onClick={() => handleTagToggle(tag)}
className={`tag ${selectedTags.includes(tag) ? "active" : ""}`}
style={{
cursor: "pointer",
padding: "6px 12px",
borderRadius: "20px",
border: selectedTags.includes(tag)
? "2px solid var(--accent-color)"
: "1px solid var(--border-color)",
backgroundColor: selectedTags.includes(tag)
? "var(--accent-color)"
: "var(--bg-tertiary)",
color: selectedTags.includes(tag) ? "#fff" : "var(--text-primary)",
transition: "all 0.2s",
}}
>
#{tag}
</button>
))}
</div>
</>
)}
{existingInSuggested.length > 0 && (
<div style={{ marginTop: "20px" }}>
<h4 style={{ margin: "0 0 10px 0", fontSize: "14px", color: "#666" }}>
Теги, которые уже есть в заметке:
</h4>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "10px",
padding: "10px",
backgroundColor: "var(--bg-tertiary)",
borderRadius: "8px",
opacity: 0.7,
}}
>
{existingInSuggested.map((tag) => (
<span
key={tag}
className="tag"
style={{
padding: "6px 12px",
borderRadius: "20px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
#{tag}
</span>
))}
</div>
</div>
)}
{selectedTags.length > 0 && (
<div
style={{
marginTop: "20px",
padding: "10px",
backgroundColor: "var(--bg-hover)",
borderRadius: "8px",
fontSize: "14px",
}}
>
<strong>Будет добавлено тегов: {selectedTags.length}</strong>
</div>
)}
</>
)}
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleApply}
disabled={isLoading || selectedTags.length === 0}
>
Применить ({selectedTags.length})
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
Отмена
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,162 @@
import React, { useState, useEffect } from "react";
import { Icon } from "@iconify/react";
interface ImproveTextModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (improvedText: string) => void;
originalText: string;
improvedText: string;
isLoading?: boolean;
hasError?: boolean;
errorMessage?: string;
}
export const ImproveTextModal: React.FC<ImproveTextModalProps> = ({
isOpen,
onClose,
onApply,
originalText,
improvedText,
isLoading = false,
hasError = false,
errorMessage,
}) => {
const [localImprovedText, setLocalImprovedText] = useState(improvedText);
useEffect(() => {
if (isOpen) {
setLocalImprovedText(improvedText);
}
}, [isOpen, improvedText]);
const handleApply = () => {
if (!localImprovedText.trim()) {
return;
}
onApply(localImprovedText);
onClose();
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal" style={{ display: "block" }} onClick={onClose}>
<div
className="modal-content"
style={{ maxWidth: "800px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Улучшенный текст</h3>
<span className="modal-close" onClick={onClose}>
&times;
</span>
</div>
<div className="modal-body" style={{ maxHeight: "70vh", overflowY: "auto" }}>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Улучшаю текст через ИИ...</p>
</div>
) : hasError ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<Icon
icon="mdi:alert-circle"
style={{ fontSize: "48px", color: "#dc3545", marginBottom: "15px" }}
/>
<p style={{ color: "#dc3545", marginBottom: "10px", fontSize: "18px" }}>
Не удалось улучшить текст
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
{errorMessage || "Произошла ошибка при улучшении текста. Проверьте настройки AI или попробуйте еще раз."}
</p>
</div>
) : (
<>
<div style={{ marginBottom: "20px" }}>
<h4 style={{ margin: "0 0 10px 0", fontSize: "16px", color: "#666" }}>
Оригинальный текст:
</h4>
<div
style={{
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-secondary)",
maxHeight: "200px",
overflowY: "auto",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
fontSize: "14px",
lineHeight: "1.6",
}}
>
{originalText || "(пусто)"}
</div>
</div>
<div>
<h4 style={{ margin: "0 0 10px 0", fontSize: "16px" }}>
Улучшенный текст:
</h4>
<textarea
value={localImprovedText}
onChange={(e) => setLocalImprovedText(e.target.value)}
style={{
width: "100%",
minHeight: "200px",
maxHeight: "400px",
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "14px",
lineHeight: "1.6",
fontFamily: "inherit",
resize: "vertical",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
overflowY: "auto",
}}
placeholder="Улучшенный текст появится здесь..."
/>
<p style={{ fontSize: "12px", color: "#666", marginTop: "8px" }}>
Вы можете отредактировать улучшенный текст перед применением
</p>
</div>
</>
)}
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleApply}
disabled={isLoading || hasError || !localImprovedText.trim()}
>
Применить
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
Отмена
</button>
</div>
</div>
</div>
);
};

View File

@ -21,6 +21,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
const [mergedContent, setMergedContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deleteOriginalNotes, setDeleteOriginalNotes] = useState(false);
const isClosedRef = useRef(false);
const { showNotification } = useNotification();
@ -34,6 +35,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setMergedContent("");
setIsLoading(false);
setIsSaving(false);
setDeleteOriginalNotes(false);
isClosedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -44,6 +46,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setIsLoading(false);
setIsSaving(false);
setMergedContent("");
setDeleteOriginalNotes(false);
onClose();
};
@ -95,7 +98,27 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
time,
});
showNotification("Объединенная заметка сохранена!", "success");
// Удаляем исходные заметки, если тумблер включен
if (deleteOriginalNotes) {
try {
await Promise.all(
selectedNotes.map((note) => offlineNotesApi.delete(note.id))
);
showNotification(
`Объединенная заметка сохранена! Удалено ${selectedNotes.length} исходных заметок.`,
"success"
);
} catch (deleteError) {
console.error("Ошибка удаления исходных заметок:", deleteError);
showNotification(
"Объединенная заметка сохранена, но произошла ошибка при удалении исходных заметок",
"warning"
);
}
} else {
showNotification("Объединенная заметка сохранена!", "success");
}
onSuccess();
handleClose();
} catch (error) {
@ -138,7 +161,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="spinner" style={{ margin: "0 auto 20px" }}></div>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Объединяю заметки через ИИ...</p>
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
Выбрано заметок: {selectedNotes.length}
@ -169,6 +192,34 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
>
<NotePreview content={mergedContent} />
</div>
<div
className="form-group ai-toggle-group"
style={{ marginTop: "20px", marginBottom: "10px" }}
>
<label className="ai-toggle-label">
<div className="toggle-label-content">
<span className="toggle-text-main">
Удалить исходные заметки
</span>
<span className="toggle-text-desc">
{deleteOriginalNotes
? "Исходные заметки будут удалены после сохранения объединенной заметки"
: "Исходные заметки останутся в списке после сохранения объединенной заметки"}
</span>
</div>
<div className="toggle-switch-wrapper">
<input
type="checkbox"
id="delete-original-notes-toggle"
className="toggle-checkbox"
checked={deleteOriginalNotes}
onChange={(e) => setDeleteOriginalNotes(e.target.checked)}
disabled={isSaving}
/>
<span className="toggle-slider"></span>
</div>
</label>
</div>
</>
)}
</div>

View File

@ -9,6 +9,9 @@ import { useNotification } from "../../hooks/useNotification";
import { offlineNotesApi } from "../../api/offlineNotesApi";
import { aiApi } from "../../api/aiApi";
import { Icon } from "@iconify/react";
import { GenerateTagsModal } from "./GenerateTagsModal";
import { ImproveTextModal } from "./ImproveTextModal";
import { extractTags } from "../../utils/markdown";
interface NoteEditorProps {
onSave: () => void;
@ -19,6 +22,14 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const [images, setImages] = useState<File[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [showTagsModal, setShowTagsModal] = useState(false);
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const [tagsGenerationError, setTagsGenerationError] = useState(false);
const [showImproveModal, setShowImproveModal] = useState(false);
const [improvedText, setImprovedText] = useState("");
const [improveError, setImproveError] = useState(false);
const [improveErrorMessage, setImproveErrorMessage] = useState<string>("");
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
const [hasSelection, setHasSelection] = useState(false);
@ -83,18 +94,85 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
}
setIsAiLoading(true);
setImproveError(false);
setImproveErrorMessage("");
setImprovedText("");
setShowImproveModal(true);
try {
const improvedText = await aiApi.improveText(content);
setContent(improvedText);
showNotification("Текст улучшен!", "success");
} catch (error) {
const improved = await aiApi.improveText(content);
setImprovedText(improved);
setImproveError(false);
} catch (error: any) {
console.error("Ошибка улучшения текста:", error);
showNotification("Ошибка улучшения текста", "error");
setImproveError(true);
setImproveErrorMessage(
error.response?.data?.error || error.message || "Ошибка улучшения текста"
);
} finally {
setIsAiLoading(false);
}
};
const handleApplyImprovedText = (text: string) => {
setContent(text);
showNotification("Текст улучшен!", "success");
setShowImproveModal(false);
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
};
const handleGenerateTags = async () => {
if (!content.trim()) {
showNotification("Введите текст для генерации тегов", "warning");
return;
}
setIsGeneratingTags(true);
setTagsGenerationError(false);
setSuggestedTags([]);
setShowTagsModal(true);
try {
const tags = await aiApi.generateTags(content);
if (tags && tags.length > 0) {
setSuggestedTags(tags);
setTagsGenerationError(false);
} else {
setTagsGenerationError(true);
setShowTagsModal(false);
showNotification("ИИ не смог предложить теги для этой заметки", "info");
}
} catch (error: any) {
console.error("Ошибка генерации тегов:", error);
console.error("Детали ошибки:", error.response?.data);
setTagsGenerationError(true);
setShowTagsModal(false);
const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов";
showNotification(errorMessage, "error");
} finally {
setIsGeneratingTags(false);
}
};
const handleSelectTags = (tags: string[]) => {
if (tags.length === 0) return;
const existingTags = extractTags(content);
const tagsToAdd = tags
.filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()))
.map((tag) => `#${tag}`)
.join(" ");
if (tagsToAdd) {
// Добавляем теги в конец заметки
const newContent = content.trim() + (content.trim() ? "\n\n" : "") + tagsToAdd;
setContent(newContent);
showNotification(`Добавлено тегов: ${tags.length}`, "success");
} else {
showNotification("Все предлагаемые теги уже есть в заметке", "info");
}
};
// Функция для определения активных форматов в выделенном тексте
const getActiveFormats = useCallback(() => {
const textarea = textareaRef.current;
@ -992,15 +1070,30 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
<div className="save-button-container">
<div className="action-buttons">
{aiEnabled && (
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<>
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
<span className="btnAI-text">
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</span>
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
<span className="btnAI-text">
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</span>
</button>
</>
)}
<button className="btnSave" onClick={handleSave}>
Сохранить
@ -1008,6 +1101,36 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
</div>
<span className="save-hint">или нажмите Alt + Enter</span>
</div>
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(content)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
<ImproveTextModal
isOpen={showImproveModal}
onClose={() => {
setShowImproveModal(false);
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
}}
onApply={handleApplyImprovedText}
originalText={content}
improvedText={improvedText}
isLoading={isAiLoading}
hasError={improveError}
errorMessage={improveErrorMessage}
/>
</div>
);
};

View File

@ -20,6 +20,8 @@ import { NotePreview } from "./NotePreview";
import { ImageUpload } from "./ImageUpload";
import { FileUpload } from "./FileUpload";
import { aiApi } from "../../api/aiApi";
import { GenerateTagsModal } from "./GenerateTagsModal";
import { extractTags } from "../../utils/markdown";
interface NoteItemProps {
note: Note;
@ -59,6 +61,10 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [localPreviewMode, setLocalPreviewMode] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isLongNote, setIsLongNote] = useState(false);
const [showTagsModal, setShowTagsModal] = useState(false);
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const [tagsGenerationError, setTagsGenerationError] = useState(false);
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -185,6 +191,57 @@ export const NoteItem: React.FC<NoteItemProps> = ({
}
};
const handleGenerateTags = async () => {
if (!editContent.trim()) {
showNotification("Введите текст для генерации тегов", "warning");
return;
}
setIsGeneratingTags(true);
setTagsGenerationError(false);
setSuggestedTags([]);
setShowTagsModal(true);
try {
const tags = await aiApi.generateTags(editContent);
if (tags && tags.length > 0) {
setSuggestedTags(tags);
setTagsGenerationError(false);
} else {
setTagsGenerationError(true);
setShowTagsModal(false);
showNotification("ИИ не смог предложить теги для этой заметки", "info");
}
} catch (error: any) {
console.error("Ошибка генерации тегов:", error);
console.error("Детали ошибки:", error.response?.data);
setTagsGenerationError(true);
setShowTagsModal(false);
const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов";
showNotification(errorMessage, "error");
} finally {
setIsGeneratingTags(false);
}
};
const handleSelectTags = (tags: string[]) => {
if (tags.length === 0) return;
const existingTags = extractTags(editContent);
const tagsToAdd = tags
.filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()))
.map((tag) => `#${tag}`)
.join(" ");
if (tagsToAdd) {
// Добавляем теги в конец заметки
const newContent = editContent.trim() + (editContent.trim() ? "\n\n" : "") + tagsToAdd;
setEditContent(newContent);
showNotification(`Добавлено тегов: ${tags.length}`, "success");
} else {
showNotification("Все предлагаемые теги уже есть в заметке", "info");
}
};
// Функция для определения активных форматов в выделенном тексте
const getActiveFormats = useCallback(() => {
const textarea = editTextareaRef.current;
@ -810,10 +867,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
}
};
const handleArchiveClick = () => {
setShowArchiveModal(true);
};
// Авторасширение textarea в режиме редактирования
useEffect(() => {
if (!isEditing) return;
@ -1136,19 +1189,6 @@ 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">
@ -1182,13 +1222,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
>
<Icon icon="mdi:pencil" />
</div>
<div
<input
type="checkbox"
checked={isSelected}
onChange={() => onSelect && onSelect(note.id)}
onClick={(e) => e.stopPropagation()}
/>
{/* <div
className="notesHeaderBtn"
onClick={handleArchiveClick}
title="В архив"
>
<Icon icon="mdi:delete" />
</div>
</div> */}
</div>
</div>
@ -1432,15 +1478,30 @@ export const NoteItem: React.FC<NoteItemProps> = ({
<div className="save-button-container">
<div className="action-buttons">
{aiEnabled && (
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<>
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
<span className="btnAI-text">
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</span>
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
<span className="btnAI-text">
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</span>
</button>
</>
)}
<button className="btnSave" onClick={handleSaveEdit}>
Сохранить
@ -1552,6 +1613,20 @@ export const NoteItem: React.FC<NoteItemProps> = ({
confirmText="Архивировать"
cancelText="Отмена"
/>
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(editContent)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
</>
);
};

View File

@ -33,6 +33,7 @@ const NotesPage: React.FC = () => {
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
@ -115,13 +116,13 @@ const NotesPage: React.FC = () => {
setIsDeleting(true);
try {
// Удаляем все выбранные заметки
// Архивируем все выбранные заметки
await Promise.all(
selectedNoteIds.map((id) => offlineNotesApi.delete(id))
selectedNoteIds.map((id) => offlineNotesApi.archive(id))
);
showNotification(
`Удалено заметок: ${selectedNoteIds.length}`,
`Архивировано заметок: ${selectedNoteIds.length}`,
"success"
);
setSelectedNoteIds([]);
@ -131,8 +132,8 @@ const NotesPage: React.FC = () => {
notesListRef.current.reloadNotes();
}
} catch (error) {
console.error("Ошибка удаления заметок:", error);
showNotification("Ошибка удаления заметок", "error");
console.error("Ошибка архивирования заметок:", error);
showNotification("Ошибка архивирования заметок", "error");
} finally {
setIsDeleting(false);
}
@ -193,7 +194,7 @@ const NotesPage: React.FC = () => {
zIndex: 1000,
}}
>
{selectedNoteIds.length >= 2 && (
{selectedNoteIds.length >= 2 && aiEnabled && (
<button
onClick={handleMergeNotes}
style={{
@ -241,15 +242,15 @@ const NotesPage: React.FC = () => {
width: "56px",
height: "56px",
borderRadius: "50%",
backgroundColor: theme === "dark" ? "#F44336" : "#E53935",
backgroundColor: theme === "dark" ? "#FF9800" : "#FF9800",
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)",
? "0 4px 12px rgba(255, 152, 0, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
@ -261,20 +262,20 @@ const NotesPage: React.FC = () => {
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)";
? "0 6px 16px rgba(255, 152, 0, 0.6)"
: "0 6px 16px rgba(255, 152, 0, 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)";
? "0 4px 12px rgba(255, 152, 0, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)";
}}
title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
title={`Архивировать ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
>
<Icon icon="mdi:delete" />
<Icon icon="mdi:archive" />
</button>
</div>
)}
@ -289,22 +290,22 @@ const NotesPage: React.FC = () => {
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteConfirm}
title="Удаление заметок"
title="Архивирование заметок"
message={
<p>
Вы уверены, что хотите удалить{" "}
Вы уверены, что хотите архивировать{" "}
<strong>{selectedNoteIds.length}</strong>{" "}
{selectedNoteIds.length === 1
? "заметку"
: selectedNoteIds.length > 4
? "заметок"
: "заметки"}
? Это действие нельзя отменить.
? Заметки можно будет восстановить из архива в настройках.
</p>
}
confirmText={isDeleting ? "Удаление..." : "Удалить"}
confirmText={isDeleting ? "Архивирование..." : "Архивировать"}
cancelText="Отмена"
confirmType="danger"
confirmType="primary"
/>
</>
);

View File

@ -245,7 +245,7 @@ const SettingsPage: React.FC = () => {
setAiEnabled(checked);
localStorage.setItem("ai_enabled", checked ? "1" : "0");
showNotification(
checked ? "Помощь ИИ включена" : "Помощь ИИ отключена",
checked ? "Функции ИИ включены" : "Функции ИИ отключены",
"success"
);
} catch (error: any) {
@ -726,11 +726,16 @@ const SettingsPage: React.FC = () => {
}`}
>
<div className="toggle-label-content">
<span className="toggle-text-main">Включить помощь ИИ</span>
<span className="toggle-text-main">Включить функции ИИ</span>
<span className="toggle-text-desc">
{checkAiSettingsFilled()
? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
: "Сначала заполните API Key, Base URL и Модель ниже"}
{checkAiSettingsFilled() ? (
<ul style={{ margin: "8px 0 0 20px", padding: 0 }}>
<li>Улучшение текста заметок</li>
<li>Объединение заметок</li>
</ul>
) : (
"Сначала заполните API Key, Base URL и Модель ниже"
)}
</span>
</div>
<div className="toggle-switch-wrapper">

View File

@ -1072,7 +1072,9 @@ textarea:focus {
.action-buttons {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
padding-right: 0;
flex-wrap: wrap;
}
.btnAI {
@ -1080,7 +1082,13 @@ textarea:focus {
color: white;
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
gap: 4px;
padding: 8px 12px;
min-height: 44px;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
}
.btnAI:hover {
@ -1103,7 +1111,7 @@ textarea:focus {
}
.btnSave {
padding: 10px 20px;
padding: 8px 16px;
cursor: pointer;
border-width: 1px;
background: var(--bg-secondary);
@ -1112,7 +1120,13 @@ textarea:focus {
border-radius: 5px;
font-family: "Open Sans", sans-serif;
transition: all 0.3s ease;
font-size: 16px;
font-size: 14px;
min-height: 44px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btnSave:hover {
@ -1154,6 +1168,58 @@ textarea:focus {
flex-wrap: wrap;
}
.note-actions input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
margin: 0;
vertical-align: middle;
flex-shrink: 0;
border: 2px solid var(--border-primary);
border-radius: 4px;
background-color: var(--bg-secondary);
transition: all 0.2s ease;
accent-color: var(--accent-color);
appearance: none;
-webkit-appearance: none;
position: relative;
}
.note-actions input[type="checkbox"]:hover {
border-color: var(--accent-color);
background-color: var(--bg-quaternary);
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.1);
}
.note-actions input[type="checkbox"]:checked {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
.note-actions input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -60%) rotate(45deg);
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
}
.note-actions input[type="checkbox"]:checked:hover {
background-color: var(--accent-color);
border-color: var(--accent-color);
opacity: 0.9;
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
}
.note-actions input[type="checkbox"]:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.15);
}
.notesHeaderBtn {
display: inline-flex;
align-items: center;
@ -1515,6 +1581,8 @@ textarea:focus {
user-select: none;
-webkit-user-select: none;
min-height: 44px;
min-width: 44px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@ -1528,6 +1596,8 @@ textarea:focus {
@media (max-width: 768px) {
.markdown-buttons.markdown-buttons--edit .btnMarkdown {
min-height: 48px;
min-width: 48px;
flex-shrink: 0;
padding: 8px 12px;
margin: 0;
}
@ -1548,6 +1618,8 @@ textarea:focus {
user-select: none;
-webkit-user-select: none;
min-height: 44px; /* Минимальная высота для touch */
min-width: 44px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@ -1668,8 +1740,9 @@ textarea:focus {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
min-width: 32px;
min-height: 32px;
min-width: 36px;
min-height: 36px;
flex-shrink: 0;
}
.floating-toolbar-btn:hover {
@ -2966,8 +3039,8 @@ textarea:focus {
}
.markdown-buttons .btnMarkdown {
flex: 0 1 auto;
min-width: auto;
flex: 0 0 auto;
min-width: 44px;
margin-right: 0;
padding: 8px 12px;
font-size: 14px;
@ -3872,6 +3945,8 @@ textarea:focus {
/* Улучшения для мобильных устройств */
.markdown-buttons .btnMarkdown {
min-height: 48px; /* Увеличиваем высоту для touch */
min-width: 48px;
flex-shrink: 0;
padding: 8px 12px;
margin: 2px;
}
@ -4189,11 +4264,17 @@ textarea:focus {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
padding: 10px 20px;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
min-height: 44px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-secondary:hover {
@ -4993,6 +5074,8 @@ textarea:focus {
/* Markdown кнопки */
.markdown-buttons .btnMarkdown {
min-height: 44px;
min-width: 44px;
flex-shrink: 0;
padding: 8px 10px;
margin: 2px;
font-size: 13px;
@ -5042,6 +5125,8 @@ textarea:focus {
}
.markdown-buttons .btnMarkdown {
min-width: 40px;
flex-shrink: 0;
padding: 6px 8px;
font-size: 12px;
}