Добавлен новый API для объединения заметок с использованием AI, включая валидацию входных данных и обработку ошибок. Обновлены компоненты для поддержки выбора заметок и интеграции с модальным окном для объединения. Обновлены стили и логика в компоненте NotesPage для улучшения пользовательского интерфейса и взаимодействия с заметками.

This commit is contained in:
Fovway 2025-11-07 13:04:53 +07:00
parent d023e20862
commit 300e881245
10 changed files with 788 additions and 10 deletions

File diff suppressed because one or more lines are too long

View File

@ -158,7 +158,7 @@
<!-- Manifest --> <!-- Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-B61qRIc-.js"></script> <script type="module" crossorigin src="/assets/index-vvy_XuzQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css"> <link rel="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> <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body> <body>

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-B61qRIc-.js",revision:"96888e49126c254a0b6fb7a9428bddb6"},{url:"assets/index-DK8OUj6L.css",revision:"b1e2c4e8724be2f2bcee585338910e99"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"ac16c70d736d7f92715a884f73979e8e"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api/,/^\/uploads/]})),e.registerRoute(({request:e})=>"navigate"===e.mode,new e.CacheFirst({cacheName:"pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\.html$/,new e.CacheFirst({cacheName:"html-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/api\./,new e.NetworkFirst({cacheName:"api-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:3600})]}),"GET"),e.registerRoute(/\/api\//,new e.NetworkFirst({cacheName:"api-cache-local",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/uploads\//,new e.CacheFirst({cacheName:"uploads-cache",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/,new e.CacheFirst({cacheName:"images-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")}); if(!self.define){let e,n={};const i=(i,c)=>(i=new URL(i+".js",c).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} 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")});

View File

@ -2546,6 +2546,151 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
} }
}); });
// API для объединения заметок через AI
app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
const { notes } = req.body;
if (!notes || !Array.isArray(notes) || notes.length < 2) {
return res
.status(400)
.json({ error: "Необходимо передать минимум 2 заметки" });
}
try {
// Получаем AI настройки пользователя
const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) {
console.error("Ошибка получения AI настроек:", err.message);
return res.status(500).json({ error: "Ошибка сервера" });
}
if (
!settings ||
!settings.openai_api_key ||
!settings.openai_base_url ||
!settings.openai_model
) {
return res
.status(400)
.json({ error: "Настройте AI настройки в параметрах" });
}
try {
// Парсим URL
const url = new URL(settings.openai_base_url);
const isHttps = url.protocol === "https:";
const hostname = url.hostname;
const port = url.port || (isHttps ? 443 : 80);
// Формируем путь, избегая дублирования
let path = url.pathname || "";
if (!path.endsWith("/chat/completions")) {
path = path.endsWith("/")
? path + "chat/completions"
: path + "/chat/completions";
}
// Формируем текст со всеми заметками
const notesText = notes
.map((note, index) => `=== Заметка ${index + 1} ===\n${note}`)
.join("\n\n");
// Подготавливаем данные для запроса
const requestData = JSON.stringify({
model: settings.openai_model,
messages: [
{
role: "system",
content:
"Ты помощник для объединения заметок. Твоя задача:\n1. Объедини следующие заметки в одну связную заметку\n2. Сохрани всю важную информацию из каждой заметки\n3. Структурируй текст логично, используй markdown форматирование (заголовки, списки, выделения)\n4. Удали дубликаты информации\n5. Если заметки на разные темы, раздели их по разделам с заголовками\n6. Сохрани даты и временные метки, если они важны\nВерни только готовый текст объединенной заметки без дополнительных пояснений.",
},
{
role: "user",
content: notesText,
},
],
temperature: 0.5,
max_tokens: 4000,
});
// Выполняем HTTP запрос
const mergedText = await new Promise((resolve, reject) => {
const options = {
hostname: hostname,
port: port,
path: path,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.openai_api_key}`,
"Content-Length": Buffer.byteLength(requestData),
},
};
const client = isHttps ? https : http;
const req = client.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const responseData = JSON.parse(data);
const mergedText =
responseData.choices[0]?.message?.content || "";
resolve(mergedText);
} catch (err) {
console.error("Ошибка парсинга ответа:", err);
reject(new Error("Ошибка обработки ответа от AI"));
}
} else {
console.error("Ошибка OpenAI API:", res.statusCode, data);
reject(
new Error(
`Ошибка OpenAI API: ${res.statusCode} - ${data.substring(
0,
100
)}`
)
);
}
});
});
req.on("error", (error) => {
console.error("Ошибка запроса к OpenAI:", error);
reject(new Error("Ошибка подключения к OpenAI API"));
});
req.write(requestData);
req.end();
});
// Логируем использование AI
logAction(
req.session.userId,
"ai_merge",
`Объединено ${notes.length} заметок через AI`
);
res.json({ success: true, mergedText });
} catch (error) {
console.error("Ошибка вызова OpenAI API:", error);
return res
.status(500)
.json({ error: error.message || "Ошибка подключения к OpenAI API" });
}
});
} catch (error) {
console.error("Ошибка объединения заметок:", error);
res.status(500).json({ error: "Ошибка объединения заметок" });
}
});
// Выход // Выход
app.post("/logout", (req, res) => { app.post("/logout", (req, res) => {
const userId = req.session.userId; const userId = req.session.userId;

View File

@ -67,7 +67,7 @@ if (!self.define) {
}); });
}; };
} }
define(['./workbox-9dc17825'], (function (workbox) { 'use strict'; define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
self.skipWaiting(); self.skipWaiting();
workbox.clientsClaim(); workbox.clientsClaim();
@ -81,13 +81,36 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
"url": "registerSW.js", "url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "/index.html",
"revision": "0.spl6nc4dqkg" "revision": "0.u6qfhq29adg"
}], {}); }], {
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), {
allowlist: [/^\/$/] allowlist: [/^\/$/],
denylist: [/^\/api/, /^\/uploads/]
})); }));
workbox.registerRoute(({
request
}) => request.mode === "navigate", new workbox.CacheFirst({
"cacheName": "pages-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 604800
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/\.html$/, new workbox.CacheFirst({
"cacheName": "html-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 604800
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({ workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({
"cacheName": "api-cache", "cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({ plugins: [new workbox.ExpirationPlugin({

View File

@ -8,4 +8,12 @@ export const aiApi = {
); );
return data.improvedText; return data.improvedText;
}, },
mergeNotes: async (notes: string[]): Promise<string> => {
const { data } = await axiosClient.post<{ mergedText: string }>(
"/ai/merge",
{ notes }
);
return data.mergedText;
},
}; };

View File

@ -0,0 +1,196 @@
import React, { useEffect, useState, useRef } from "react";
import { aiApi } from "../../api/aiApi";
import { offlineNotesApi } from "../../api/offlineNotesApi";
import { Note } from "../../types/note";
import { NotePreview } from "./NotePreview";
import { useNotification } from "../../hooks/useNotification";
interface MergeNotesModalProps {
isOpen: boolean;
onClose: () => void;
selectedNotes: Note[];
onSuccess: () => void;
}
export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
isOpen,
onClose,
selectedNotes,
onSuccess,
}) => {
const [mergedContent, setMergedContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isClosedRef = useRef(false);
const { showNotification } = useNotification();
// Выполняем объединение при открытии модального окна
useEffect(() => {
if (isOpen && selectedNotes.length >= 2) {
isClosedRef.current = false;
handleMerge();
} else {
// Сбрасываем состояние при закрытии
setMergedContent("");
setIsLoading(false);
setIsSaving(false);
isClosedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleClose = () => {
isClosedRef.current = true;
setIsLoading(false);
setIsSaving(false);
setMergedContent("");
onClose();
};
const handleMerge = async () => {
setIsLoading(true);
setMergedContent("");
try {
const notesContent = selectedNotes.map((note) => note.content);
const merged = await aiApi.mergeNotes(notesContent);
// Проверяем, не была ли модалка закрыта во время запроса
if (!isClosedRef.current) {
setMergedContent(merged);
}
} catch (error) {
// Игнорируем ошибку, если модалка была закрыта
if (isClosedRef.current) {
return;
}
console.error("Ошибка объединения заметок:", error);
showNotification("Ошибка объединения заметок", "error");
handleClose();
} finally {
if (!isClosedRef.current) {
setIsLoading(false);
}
}
};
const handleSave = async () => {
if (!mergedContent.trim()) {
showNotification("Нет контента для сохранения", "warning");
return;
}
setIsSaving(true);
try {
const now = new Date();
const date = now.toLocaleDateString("ru-RU");
const time = now.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
await offlineNotesApi.create({
content: mergedContent,
date,
time,
});
showNotification("Объединенная заметка сохранена!", "success");
onSuccess();
handleClose();
} catch (error) {
console.error("Ошибка сохранения заметки:", error);
showNotification("Ошибка сохранения заметки", "error");
} finally {
setIsSaving(false);
}
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal" style={{ display: "block" }} onClick={handleClose}>
<div
className="modal-content"
style={{ maxWidth: "800px", maxHeight: "80vh", overflow: "auto" }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Объединение заметок</h3>
<span className="modal-close" onClick={handleClose}>
&times;
</span>
</div>
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Объединяю заметки через ИИ...</p>
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
Выбрано заметок: {selectedNotes.length}
</p>
</div>
) : (
<>
<div style={{ marginBottom: "15px", color: "#666" }}>
<p>
Результат объединения {selectedNotes.length}{" "}
{selectedNotes.length === 2
? "заметок"
: selectedNotes.length > 4
? "заметок"
: "заметок"}
:
</p>
</div>
<div
style={{
border: "1px solid var(--border-color)",
borderRadius: "8px",
padding: "15px",
backgroundColor: "var(--bg-secondary)",
maxHeight: "400px",
overflow: "auto",
}}
>
<NotePreview content={mergedContent} />
</div>
</>
)}
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleSave}
disabled={isLoading || isSaving || !mergedContent}
style={{ marginRight: "10px" }}
>
{isSaving ? "Сохранение..." : "Сохранить"}
</button>
<button
className="btn-secondary"
onClick={handleClose}
disabled={isSaving}
>
{isLoading ? "Отменить" : "Отмена"}
</button>
</div>
</div>
</div>
);
};

View File

@ -27,6 +27,8 @@ interface NoteItemProps {
onPin: (id: number | string) => void; onPin: (id: number | string) => void;
onArchive: (id: number | string) => void; onArchive: (id: number | string) => void;
onReload: () => void; onReload: () => void;
isSelected?: boolean;
onSelect?: (id: number | string) => void;
} }
export const NoteItem: React.FC<NoteItemProps> = ({ export const NoteItem: React.FC<NoteItemProps> = ({
@ -35,6 +37,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
onPin, onPin,
onArchive, onArchive,
onReload, onReload,
isSelected = false,
onSelect,
}) => { }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(note.content); const [editContent, setEditContent] = useState(note.content);
@ -1132,6 +1136,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
> >
<div className="date"> <div className="date">
<span className="date-text"> <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()} {formatDate()}
{note.is_pinned ? ( {note.is_pinned ? (
<span className="pin-indicator"> <span className="pin-indicator">

View File

@ -10,7 +10,13 @@ export interface NotesListRef {
reloadNotes: () => void; reloadNotes: () => void;
} }
export const NotesList = forwardRef<NotesListRef>((_props, ref) => { interface NotesListProps {
selectedNoteIds?: (number | string)[];
onNoteSelect?: (noteId: number | string) => void;
}
export const NotesList = forwardRef<NotesListRef, NotesListProps>(
({ selectedNoteIds = [], onNoteSelect }, ref) => {
const notes = useAppSelector((state) => state.notes.notes); const notes = useAppSelector((state) => state.notes.notes);
const userId = useAppSelector((state) => state.auth.userId); const userId = useAppSelector((state) => state.auth.userId);
const searchQuery = useAppSelector((state) => state.notes.searchQuery); const searchQuery = useAppSelector((state) => state.notes.searchQuery);
@ -159,6 +165,8 @@ export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
onPin={handlePin} onPin={handlePin}
onArchive={handleArchive} onArchive={handleArchive}
onReload={loadNotes} onReload={loadNotes}
isSelected={selectedNoteIds.includes(note.id)}
onSelect={onNoteSelect}
/> />
))} ))}
</div> </div>

View File

@ -1,11 +1,17 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Icon } from "@iconify/react";
import { Header } from "../components/layout/Header"; import { Header } from "../components/layout/Header";
import { Sidebar } from "../components/layout/Sidebar"; import { Sidebar } from "../components/layout/Sidebar";
import { MobileSidebar } from "../components/layout/MobileSidebar"; import { MobileSidebar } from "../components/layout/MobileSidebar";
import { NoteEditor } from "../components/notes/NoteEditor"; import { NoteEditor } from "../components/notes/NoteEditor";
import { NotesList, NotesListRef } from "../components/notes/NotesList"; import { NotesList, NotesListRef } from "../components/notes/NotesList";
import { ImageModal } from "../components/common/ImageModal"; import { ImageModal } from "../components/common/ImageModal";
import { MergeNotesModal } from "../components/notes/MergeNotesModal";
import { Modal } from "../components/common/Modal";
import { offlineNotesApi } from "../api/offlineNotesApi";
import { useAppSelector, useAppDispatch } from "../store/hooks"; import { useAppSelector, useAppDispatch } from "../store/hooks";
import { useTheme } from "../hooks/useTheme";
import { useNotification } from "../hooks/useNotification";
import { import {
setSelectedDate, setSelectedDate,
setSelectedTag, setSelectedTag,
@ -14,9 +20,16 @@ import {
const NotesPage: React.FC = () => { const NotesPage: React.FC = () => {
const allNotes = useAppSelector((state) => state.notes.allNotes); const allNotes = useAppSelector((state) => state.notes.allNotes);
const notes = useAppSelector((state) => state.notes.notes);
const notesListRef = useRef<NotesListRef>(null); const notesListRef = useRef<NotesListRef>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [selectedNoteIds, setSelectedNoteIds] = useState<(number | string)[]>([]);
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { theme } = useTheme();
const { showNotification } = useNotification();
const selectedDate = useAppSelector((state) => state.notes.selectedDate); const selectedDate = useAppSelector((state) => state.notes.selectedDate);
const selectedTag = useAppSelector((state) => state.notes.selectedTag); const selectedTag = useAppSelector((state) => state.notes.selectedTag);
const searchQuery = useAppSelector((state) => state.notes.searchQuery); const searchQuery = useAppSelector((state) => state.notes.searchQuery);
@ -64,6 +77,71 @@ const NotesPage: React.FC = () => {
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
}; };
const handleNoteSelect = (noteId: number | string) => {
setSelectedNoteIds((prev) => {
if (prev.includes(noteId)) {
return prev.filter((id) => id !== noteId);
} else {
return [...prev, noteId];
}
});
};
const handleMergeNotes = () => {
if (selectedNoteIds.length < 2) {
return;
}
setIsMergeModalOpen(true);
};
const handleMergeSuccess = () => {
setSelectedNoteIds([]);
if (notesListRef.current) {
notesListRef.current.reloadNotes();
}
};
const handleDeleteClick = () => {
if (selectedNoteIds.length === 0) {
return;
}
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (selectedNoteIds.length === 0) {
return;
}
setIsDeleting(true);
try {
// Удаляем все выбранные заметки
await Promise.all(
selectedNoteIds.map((id) => offlineNotesApi.delete(id))
);
showNotification(
`Удалено заметок: ${selectedNoteIds.length}`,
"success"
);
setSelectedNoteIds([]);
setIsDeleteModalOpen(false);
if (notesListRef.current) {
notesListRef.current.reloadNotes();
}
} catch (error) {
console.error("Ошибка удаления заметок:", error);
showNotification("Ошибка удаления заметок", "error");
} finally {
setIsDeleting(false);
}
};
const selectedNotes = notes.filter((note) =>
selectedNoteIds.includes(note.id)
);
return ( return (
<> <>
<MobileSidebar <MobileSidebar
@ -82,11 +160,152 @@ const NotesPage: React.FC = () => {
<button onClick={handleClearFilters}></button> <button onClick={handleClearFilters}></button>
</div> </div>
)} )}
{selectedNoteIds.length > 0 && (
<div className="filter-indicator">
<span className="filter-indicator-text">
Выделено заметок: {selectedNoteIds.length}
</span>{" "}
<button
onClick={() => setSelectedNoteIds([])}
title="Снять выделение"
>
</button>
</div>
)}
<NoteEditor onSave={handleNoteSave} /> <NoteEditor onSave={handleNoteSave} />
</div> </div>
<NotesList ref={notesListRef} /> <NotesList
ref={notesListRef}
selectedNoteIds={selectedNoteIds}
onNoteSelect={handleNoteSelect}
/>
</div> </div>
{selectedNoteIds.length > 0 && (
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
display: "flex",
flexDirection: "column",
gap: "12px",
zIndex: 1000,
}}
>
{selectedNoteIds.length >= 2 && (
<button
onClick={handleMergeNotes}
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
backgroundColor:
theme === "dark" ? "#4CAF50" : "#2196F3",
color: "white",
border: "none",
cursor: "pointer",
boxShadow:
theme === "dark"
? "0 4px 12px rgba(76, 175, 80, 0.4)"
: "0 4px 12px rgba(33, 150, 243, 0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "24px",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.1)";
e.currentTarget.style.boxShadow =
theme === "dark"
? "0 6px 16px rgba(76, 175, 80, 0.6)"
: "0 6px 16px rgba(33, 150, 243, 0.6)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow =
theme === "dark"
? "0 4px 12px rgba(76, 175, 80, 0.4)"
: "0 4px 12px rgba(33, 150, 243, 0.4)";
}}
title={`Объединить ${selectedNoteIds.length} заметок`}
>
<Icon icon="mdi:link-variant" />
</button>
)}
<button
onClick={handleDeleteClick}
disabled={isDeleting}
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
backgroundColor: theme === "dark" ? "#F44336" : "#E53935",
color: "white",
border: "none",
cursor: isDeleting ? "not-allowed" : "pointer",
opacity: isDeleting ? 0.6 : 1,
boxShadow:
theme === "dark"
? "0 4px 12px rgba(244, 67, 54, 0.4)"
: "0 4px 12px rgba(229, 57, 53, 0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "24px",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
if (!isDeleting) {
e.currentTarget.style.transform = "scale(1.1)";
e.currentTarget.style.boxShadow =
theme === "dark"
? "0 6px 16px rgba(244, 67, 54, 0.6)"
: "0 6px 16px rgba(229, 57, 53, 0.6)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow =
theme === "dark"
? "0 4px 12px rgba(244, 67, 54, 0.4)"
: "0 4px 12px rgba(229, 57, 53, 0.4)";
}}
title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
>
<Icon icon="mdi:delete" />
</button>
</div>
)}
<ImageModal /> <ImageModal />
<MergeNotesModal
isOpen={isMergeModalOpen}
onClose={() => setIsMergeModalOpen(false)}
selectedNotes={selectedNotes}
onSuccess={handleMergeSuccess}
/>
<Modal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteConfirm}
title="Удаление заметок"
message={
<p>
Вы уверены, что хотите удалить{" "}
<strong>{selectedNoteIds.length}</strong>{" "}
{selectedNoteIds.length === 1
? "заметку"
: selectedNoteIds.length > 4
? "заметок"
: "заметки"}
? Это действие нельзя отменить.
</p>
}
confirmText={isDeleting ? "Удаление..." : "Удалить"}
cancelText="Отмена"
confirmType="danger"
/>
</> </>
); );
}; };