Compare commits

..

No commits in common. "1d4cf84d6daa95b1ab85787b315818bf4b4c159d" and "300e881245c1e9ad3946111251387ad30d785baa" have entirely different histories.

18 changed files with 87 additions and 1390 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-Tw4PJyEU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3lVJ81m.css">
<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>
<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 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")});
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")});

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, ai_enabled FROM users WHERE id = ?";
"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);
@ -2441,13 +2441,6 @@ 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);
@ -2553,356 +2546,6 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
}
});
// API для генерации тегов через AI
app.post("/api/ai/generate-tags", requireApiAuth, async (req, res) => {
const { text } = req.body;
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, 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);
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;
@ -2916,7 +2559,7 @@ app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?";
"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);
@ -2934,13 +2577,6 @@ 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);

View File

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

View File

@ -16,12 +16,4 @@ 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

@ -1,265 +0,0 @@
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

@ -1,162 +0,0 @@
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,7 +21,6 @@ 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();
@ -35,7 +34,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setMergedContent("");
setIsLoading(false);
setIsSaving(false);
setDeleteOriginalNotes(false);
isClosedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -46,7 +44,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setIsLoading(false);
setIsSaving(false);
setMergedContent("");
setDeleteOriginalNotes(false);
onClose();
};
@ -98,27 +95,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
time,
});
// Удаляем исходные заметки, если тумблер включен
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");
}
showNotification("Объединенная заметка сохранена!", "success");
onSuccess();
handleClose();
} catch (error) {
@ -161,7 +138,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<div className="spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Объединяю заметки через ИИ...</p>
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
Выбрано заметок: {selectedNotes.length}
@ -192,34 +169,6 @@ 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,9 +9,6 @@ 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;
@ -22,14 +19,6 @@ 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);
@ -94,85 +83,18 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
}
setIsAiLoading(true);
setImproveError(false);
setImproveErrorMessage("");
setImprovedText("");
setShowImproveModal(true);
try {
const improved = await aiApi.improveText(content);
setImprovedText(improved);
setImproveError(false);
} catch (error: any) {
const improvedText = await aiApi.improveText(content);
setContent(improvedText);
showNotification("Текст улучшен!", "success");
} catch (error) {
console.error("Ошибка улучшения текста:", error);
setImproveError(true);
setImproveErrorMessage(
error.response?.data?.error || error.message || "Ошибка улучшения текста"
);
showNotification("Ошибка улучшения текста", "error");
} 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;
@ -1070,30 +992,15 @@ 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" />
<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 btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
)}
<button className="btnSave" onClick={handleSave}>
Сохранить
@ -1101,36 +1008,6 @@ 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,8 +20,6 @@ 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;
@ -61,10 +59,6 @@ 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);
@ -191,57 +185,6 @@ 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;
@ -867,6 +810,10 @@ export const NoteItem: React.FC<NoteItemProps> = ({
}
};
const handleArchiveClick = () => {
setShowArchiveModal(true);
};
// Авторасширение textarea в режиме редактирования
useEffect(() => {
if (!isEditing) return;
@ -1189,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">
@ -1222,19 +1182,13 @@ export const NoteItem: React.FC<NoteItemProps> = ({
>
<Icon icon="mdi:pencil" />
</div>
<input
type="checkbox"
checked={isSelected}
onChange={() => onSelect && onSelect(note.id)}
onClick={(e) => e.stopPropagation()}
/>
{/* <div
<div
className="notesHeaderBtn"
onClick={handleArchiveClick}
title="В архив"
>
<Icon icon="mdi:delete" />
</div> */}
</div>
</div>
</div>
@ -1478,30 +1432,15 @@ 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" />
<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 btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
)}
<button className="btnSave" onClick={handleSaveEdit}>
Сохранить
@ -1613,20 +1552,6 @@ 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,7 +33,6 @@ 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);
@ -116,13 +115,13 @@ const NotesPage: React.FC = () => {
setIsDeleting(true);
try {
// Архивируем все выбранные заметки
// Удаляем все выбранные заметки
await Promise.all(
selectedNoteIds.map((id) => offlineNotesApi.archive(id))
selectedNoteIds.map((id) => offlineNotesApi.delete(id))
);
showNotification(
`Архивировано заметок: ${selectedNoteIds.length}`,
`Удалено заметок: ${selectedNoteIds.length}`,
"success"
);
setSelectedNoteIds([]);
@ -132,8 +131,8 @@ const NotesPage: React.FC = () => {
notesListRef.current.reloadNotes();
}
} catch (error) {
console.error("Ошибка архивирования заметок:", error);
showNotification("Ошибка архивирования заметок", "error");
console.error("Ошибка удаления заметок:", error);
showNotification("Ошибка удаления заметок", "error");
} finally {
setIsDeleting(false);
}
@ -194,7 +193,7 @@ const NotesPage: React.FC = () => {
zIndex: 1000,
}}
>
{selectedNoteIds.length >= 2 && aiEnabled && (
{selectedNoteIds.length >= 2 && (
<button
onClick={handleMergeNotes}
style={{
@ -242,15 +241,15 @@ const NotesPage: React.FC = () => {
width: "56px",
height: "56px",
borderRadius: "50%",
backgroundColor: theme === "dark" ? "#FF9800" : "#FF9800",
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(255, 152, 0, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)",
? "0 4px 12px rgba(244, 67, 54, 0.4)"
: "0 4px 12px rgba(229, 57, 53, 0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
@ -262,20 +261,20 @@ const NotesPage: React.FC = () => {
e.currentTarget.style.transform = "scale(1.1)";
e.currentTarget.style.boxShadow =
theme === "dark"
? "0 6px 16px rgba(255, 152, 0, 0.6)"
: "0 6px 16px rgba(255, 152, 0, 0.6)";
? "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(255, 152, 0, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)";
? "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 ? "заметок" : "заметки"}`}
title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
>
<Icon icon="mdi:archive" />
<Icon icon="mdi:delete" />
</button>
</div>
)}
@ -290,22 +289,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="primary"
confirmType="danger"
/>
</>
);

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,16 +726,11 @@ 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() ? (
<ul style={{ margin: "8px 0 0 20px", padding: 0 }}>
<li>Улучшение текста заметок</li>
<li>Объединение заметок</li>
</ul>
) : (
"Сначала заполните API Key, Base URL и Модель ниже"
)}
{checkAiSettingsFilled()
? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
: "Сначала заполните API Key, Base URL и Модель ниже"}
</span>
</div>
<div className="toggle-switch-wrapper">

View File

@ -1072,9 +1072,7 @@ textarea:focus {
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
padding-right: 0;
flex-wrap: wrap;
gap: 10px;
}
.btnAI {
@ -1082,13 +1080,7 @@ textarea:focus {
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
min-height: 44px;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
gap: 6px;
}
.btnAI:hover {
@ -1111,7 +1103,7 @@ textarea:focus {
}
.btnSave {
padding: 8px 16px;
padding: 10px 20px;
cursor: pointer;
border-width: 1px;
background: var(--bg-secondary);
@ -1120,13 +1112,7 @@ textarea:focus {
border-radius: 5px;
font-family: "Open Sans", sans-serif;
transition: all 0.3s ease;
font-size: 14px;
min-height: 44px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
}
.btnSave:hover {
@ -1168,58 +1154,6 @@ 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;
@ -1581,8 +1515,6 @@ 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;
@ -1596,8 +1528,6 @@ 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;
}
@ -1618,8 +1548,6 @@ 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;
@ -1740,9 +1668,8 @@ textarea:focus {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
min-width: 36px;
min-height: 36px;
flex-shrink: 0;
min-width: 32px;
min-height: 32px;
}
.floating-toolbar-btn:hover {
@ -3039,8 +2966,8 @@ textarea:focus {
}
.markdown-buttons .btnMarkdown {
flex: 0 0 auto;
min-width: 44px;
flex: 0 1 auto;
min-width: auto;
margin-right: 0;
padding: 8px 12px;
font-size: 14px;
@ -3945,8 +3872,6 @@ textarea:focus {
/* Улучшения для мобильных устройств */
.markdown-buttons .btnMarkdown {
min-height: 48px; /* Увеличиваем высоту для touch */
min-width: 48px;
flex-shrink: 0;
padding: 8px 12px;
margin: 2px;
}
@ -4264,17 +4189,11 @@ textarea:focus {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
padding: 8px 16px;
padding: 10px 20px;
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 {
@ -5074,8 +4993,6 @@ textarea:focus {
/* Markdown кнопки */
.markdown-buttons .btnMarkdown {
min-height: 44px;
min-width: 44px;
flex-shrink: 0;
padding: 8px 10px;
margin: 2px;
font-size: 13px;
@ -5125,8 +5042,6 @@ textarea:focus {
}
.markdown-buttons .btnMarkdown {
min-width: 40px;
flex-shrink: 0;
padding: 6px 8px;
font-size: 12px;
}