Обновлен файл .gitignore для исключения новых временных и кэшированных файлов, а также добавлены переменные окружения для разработки. Обновлены файлы документации с добавлением информации о ключе шифрования. Внесены изменения в серверный код для поддержки шифрования и дешифрования содержимого заметок. Обновлены компоненты для улучшения работы с заметками и их отображением. Оптимизированы стили и скрипты для улучшения пользовательского интерфейса.
This commit is contained in:
parent
82094e46b5
commit
e6ebf2cbff
69
.gitignore
vendored
69
.gitignore
vendored
@ -10,17 +10,30 @@ lerna-debug.log*
|
||||
# Dependencies
|
||||
node_modules
|
||||
backend/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
build
|
||||
*.local
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
.vite/*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/.env.*.local
|
||||
|
||||
# Database files
|
||||
backend/database/*.db
|
||||
@ -31,11 +44,29 @@ backend/database/*.db-wal
|
||||
backend/public/uploads/*
|
||||
!backend/public/uploads/.gitkeep
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
.parcel-cache
|
||||
.turbo
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
@ -47,4 +78,40 @@ public/sw.js.map
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.backup
|
||||
*~
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# Storybook build outputs
|
||||
storybook-static
|
||||
|
||||
# Deployment
|
||||
.vercel
|
||||
.netlify
|
||||
.firebase
|
||||
.aws-sam
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@ -110,8 +110,11 @@ cd ..
|
||||
PORT=3001
|
||||
SESSION_SECRET=your-secret-key-here
|
||||
NODE_ENV=development
|
||||
ENCRYPTION_KEY=your-encryption-key-min-32-chars
|
||||
```
|
||||
|
||||
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
|
||||
|
||||
---
|
||||
|
||||
## 📍 Порты
|
||||
|
||||
@ -92,8 +92,11 @@ npm run lint
|
||||
PORT=3001
|
||||
SESSION_SECRET=замените-на-свой-секретный-ключ
|
||||
NODE_ENV=development
|
||||
ENCRYPTION_KEY=замените-на-свой-ключ-минимум-32-символа
|
||||
```
|
||||
|
||||
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
|
||||
|
||||
### 2. Первый пользователь
|
||||
|
||||
1. Откройте http://localhost:5173
|
||||
|
||||
@ -171,8 +171,14 @@ npm run lint
|
||||
PORT=3001
|
||||
SESSION_SECRET=your-secret-key-here
|
||||
NODE_ENV=development
|
||||
ENCRYPTION_KEY=your-encryption-key-min-32-chars
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- `ENCRYPTION_KEY` - ключ для шифрования заметок в базе данных (минимум 32 символа)
|
||||
- В продакшене ОБЯЗАТЕЛЬНО используйте надежный случайный ключ
|
||||
- Не используйте дефолтный ключ в продакшене!
|
||||
|
||||
### AI настройки
|
||||
|
||||
Настройки AI выполняются через интерфейс приложения в разделе "Настройки".
|
||||
|
||||
@ -16,8 +16,14 @@ npm install
|
||||
PORT=3001
|
||||
SESSION_SECRET=your-secret-key-here-change-in-production
|
||||
NODE_ENV=development
|
||||
ENCRYPTION_KEY=your-encryption-key-minimum-32-characters-long
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- `ENCRYPTION_KEY` - ключ для шифрования заметок в базе данных (минимум 32 символа)
|
||||
- В продакшене ОБЯЗАТЕЛЬНО используйте надежный случайный ключ
|
||||
- Потеря ключа приведет к невозможности дешифрования существующих заметок!
|
||||
|
||||
## Запуск
|
||||
|
||||
### Отдельный запуск бэкенда:
|
||||
|
||||
162
backend/public/assets/index-42KwbWCP.js
Normal file
162
backend/public/assets/index-42KwbWCP.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
backend/public/assets/index-DK8OUj6L.css
Normal file
1
backend/public/assets/index-DK8OUj6L.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,2 +0,0 @@
|
||||
try{self["workbox:window:7.2.0"]&&_()}catch{}function E(n,r){return new Promise(function(t){var i=new MessageChannel;i.port1.onmessage=function(c){t(c.data)},n.postMessage(r,[i.port2])})}function W(n){var r=function(t,i){if(typeof t!="object"||!t)return t;var c=t[Symbol.toPrimitive];if(c!==void 0){var h=c.call(t,i);if(typeof h!="object")return h;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(n,"string");return typeof r=="symbol"?r:r+""}function k(n,r){for(var t=0;t<r.length;t++){var i=r[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(n,W(i.key),i)}}function P(n,r){return P=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,i){return t.__proto__=i,t},P(n,r)}function j(n,r){(r==null||r>n.length)&&(r=n.length);for(var t=0,i=new Array(r);t<r;t++)i[t]=n[t];return i}function L(n,r){var t=typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(t)return(t=t.call(n)).next.bind(t);if(Array.isArray(n)||(t=function(c,h){if(c){if(typeof c=="string")return j(c,h);var l=Object.prototype.toString.call(c).slice(8,-1);return l==="Object"&&c.constructor&&(l=c.constructor.name),l==="Map"||l==="Set"?Array.from(c):l==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(l)?j(c,h):void 0}}(n))||r){t&&(n=t);var i=0;return function(){return i>=n.length?{done:!0}:{done:!1,value:n[i++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.
|
||||
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}try{self["workbox:core:7.2.0"]&&_()}catch{}var w=function(){var n=this;this.promise=new Promise(function(r,t){n.resolve=r,n.reject=t})};function b(n,r){var t=location.href;return new URL(n,t).href===new URL(r,t).href}var g=function(n,r){this.type=n,Object.assign(this,r)};function d(n,r,t){return t?r?r(n):n:(n&&n.then||(n=Promise.resolve(n)),r?n.then(r):n)}function O(){}var x={type:"SKIP_WAITING"};function S(n,r){return n&&n.then?n.then(O):Promise.resolve()}var U=function(n){function r(v,u){var e,o;return u===void 0&&(u={}),(e=n.call(this)||this).nn={},e.tn=0,e.rn=new w,e.en=new w,e.on=new w,e.un=0,e.an=new Set,e.cn=function(){var s=e.fn,a=s.installing;e.tn>0||!b(a.scriptURL,e.sn.toString())||performance.now()>e.un+6e4?(e.vn=a,s.removeEventListener("updatefound",e.cn)):(e.hn=a,e.an.add(a),e.rn.resolve(a)),++e.tn,a.addEventListener("statechange",e.ln)},e.ln=function(s){var a=e.fn,f=s.target,p=f.state,m=f===e.vn,y={sw:f,isExternal:m,originalEvent:s};!m&&e.mn&&(y.isUpdate=!0),e.dispatchEvent(new g(p,y)),p==="installed"?e.wn=self.setTimeout(function(){p==="installed"&&a.waiting===f&&e.dispatchEvent(new g("waiting",y))},200):p==="activating"&&(clearTimeout(e.wn),m||e.en.resolve(f))},e.yn=function(s){var a=e.hn,f=a!==navigator.serviceWorker.controller;e.dispatchEvent(new g("controlling",{isExternal:f,originalEvent:s,sw:a,isUpdate:e.mn})),f||e.on.resolve(a)},e.gn=(o=function(s){var a=s.data,f=s.ports,p=s.source;return d(e.getSW(),function(){e.an.has(p)&&e.dispatchEvent(new g("message",{data:a,originalEvent:s,ports:f,sw:p}))})},function(){for(var s=[],a=0;a<arguments.length;a++)s[a]=arguments[a];try{return Promise.resolve(o.apply(this,s))}catch(f){return Promise.reject(f)}}),e.sn=v,e.nn=u,navigator.serviceWorker.addEventListener("message",e.gn),e}var t,i;i=n,(t=r).prototype=Object.create(i.prototype),t.prototype.constructor=t,P(t,i);var c,h,l=r.prototype;return l.register=function(v){var u=(v===void 0?{}:v).immediate,e=u!==void 0&&u;try{var o=this;return d(function(s,a){var f=s();return f&&f.then?f.then(a):a(f)}(function(){if(!e&&document.readyState!=="complete")return S(new Promise(function(s){return window.addEventListener("load",s)}))},function(){return o.mn=!!navigator.serviceWorker.controller,o.dn=o.pn(),d(o.bn(),function(s){o.fn=s,o.dn&&(o.hn=o.dn,o.en.resolve(o.dn),o.on.resolve(o.dn),o.dn.addEventListener("statechange",o.ln,{once:!0}));var a=o.fn.waiting;return a&&b(a.scriptURL,o.sn.toString())&&(o.hn=a,Promise.resolve().then(function(){o.dispatchEvent(new g("waiting",{sw:a,wasWaitingBeforeRegister:!0}))}).then(function(){})),o.hn&&(o.rn.resolve(o.hn),o.an.add(o.hn)),o.fn.addEventListener("updatefound",o.cn),navigator.serviceWorker.addEventListener("controllerchange",o.yn),o.fn})}))}catch(s){return Promise.reject(s)}},l.update=function(){try{return this.fn?d(S(this.fn.update())):d()}catch(v){return Promise.reject(v)}},l.getSW=function(){return this.hn!==void 0?Promise.resolve(this.hn):this.rn.promise},l.messageSW=function(v){try{return d(this.getSW(),function(u){return E(u,v)})}catch(u){return Promise.reject(u)}},l.messageSkipWaiting=function(){this.fn&&this.fn.waiting&&E(this.fn.waiting,x)},l.pn=function(){var v=navigator.serviceWorker.controller;return v&&b(v.scriptURL,this.sn.toString())?v:void 0},l.bn=function(){try{var v=this;return d(function(u,e){try{var o=u()}catch(s){return e(s)}return o&&o.then?o.then(void 0,e):o}(function(){return d(navigator.serviceWorker.register(v.sn,v.nn),function(u){return v.un=performance.now(),u})},function(u){throw u}))}catch(u){return Promise.reject(u)}},c=r,(h=[{key:"active",get:function(){return this.en.promise}},{key:"controlling",get:function(){return this.on.promise}}])&&k(c.prototype,h),Object.defineProperty(c,"prototype",{writable:!1}),c}(function(){function n(){this.Pn=new Map}var r=n.prototype;return r.addEventListener=function(t,i){this.jn(t).add(i)},r.removeEventListener=function(t,i){this.jn(t).delete(i)},r.dispatchEvent=function(t){t.target=this;for(var i,c=L(this.jn(t.type));!(i=c()).done;)(0,i.value)(t)},r.jn=function(t){return this.Pn.has(t)||this.Pn.set(t,new Set),this.Pn.get(t)},n}());export{U as Workbox,g as WorkboxEvent,E as messageSW};
|
||||
@ -158,10 +158,78 @@
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script type="module" crossorigin src="/assets/index-CRKRzJj1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-QEK5TGz3.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"></head>
|
||||
<script type="module" crossorigin src="/assets/index-42KwbWCP.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"></div>
|
||||
<div id="root">
|
||||
<!-- Индикатор загрузки до монтирования React -->
|
||||
<div id="initial-loading" style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
">
|
||||
<style>
|
||||
#initial-loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid transparent;
|
||||
border-top: 4px solid var(--accent-color, #007bff);
|
||||
border-right: 4px solid var(--accent-color, #007bff);
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 4px solid transparent;
|
||||
border-radius: 50%;
|
||||
animation: initial-loading-spin 0.8s linear infinite;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@keyframes initial-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<div id="initial-loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Скрываем индикатор загрузки сразу после загрузки DOM
|
||||
// React удалит этот элемент при первом рендере через createRoot
|
||||
(function() {
|
||||
// Используем MutationObserver для отслеживания изменений в #root
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
// Если React начал добавлять элементы в #root, удаляем индикатор
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
const loadingEl = document.getElementById('initial-loading');
|
||||
if (loadingEl && loadingEl.parentNode) {
|
||||
loadingEl.parentNode.removeChild(loadingEl);
|
||||
}
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Начинаем наблюдение за изменениями в #root
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
|
||||
// Фолбэк: если через 2 секунды элемент все еще есть, удаляем вручную
|
||||
setTimeout(function() {
|
||||
const loadingEl = document.getElementById('initial-loading');
|
||||
if (loadingEl && loadingEl.parentNode) {
|
||||
loadingEl.parentNode.removeChild(loadingEl);
|
||||
}
|
||||
observer.disconnect();
|
||||
}, 2000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -9,27 +9,10 @@
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"lang": "ru",
|
||||
"id": "/",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"prefer_related_applications": false,
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"dir": "ltr",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Новая заметка",
|
||||
"short_name": "Новая",
|
||||
"description": "Создать новую заметку",
|
||||
"url": "/notes?new=true",
|
||||
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Мой профиль",
|
||||
"short_name": "Профиль",
|
||||
"description": "Открыть профиль",
|
||||
"url": "/profile",
|
||||
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// Загружаем .env файл ПЕРВЫМ, до всех остальных импортов
|
||||
require("dotenv").config();
|
||||
|
||||
const express = require("express");
|
||||
const sqlite3 = require("sqlite3").verbose();
|
||||
const bcrypt = require("bcryptjs");
|
||||
@ -11,7 +14,7 @@ const multer = require("multer");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
require("dotenv").config();
|
||||
const { encrypt, decrypt } = require("./utils/encryption");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@ -659,7 +662,7 @@ app.get("/register", (req, res) => {
|
||||
if (req.session.authenticated) {
|
||||
return res.redirect("/notes");
|
||||
}
|
||||
res.sendFile(path.join(__dirname, "public", "register.html"));
|
||||
res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
});
|
||||
|
||||
// API регистрации
|
||||
@ -815,19 +818,19 @@ app.get("/notes", requireAuth, (req, res) => {
|
||||
db.get(sql, [req.session.userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "notes.html"));
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
const accentColor = user?.accent_color || "#007bff";
|
||||
|
||||
// Читаем HTML файл
|
||||
fs.readFile(
|
||||
path.join(__dirname, "public", "notes.html"),
|
||||
path.join(__dirname, "public", "index.html"),
|
||||
"utf8",
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
console.error("Ошибка чтения файла notes.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "notes.html"));
|
||||
console.error("Ошибка чтения файла index.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
||||
@ -857,11 +860,12 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
|
||||
let whereClause = "WHERE n.user_id = ? AND n.is_archived = 0";
|
||||
let params = [req.session.userId];
|
||||
|
||||
// Поиск по тексту (регистронезависимый)
|
||||
if (q && q.trim()) {
|
||||
whereClause += " AND LOWER(n.content) LIKE ?";
|
||||
params.push(`%${q.trim().toLowerCase()}%`);
|
||||
}
|
||||
// Поиск по тексту - убран из SQL, так как зашифрованный текст нельзя искать
|
||||
// Поиск будет выполняться на клиенте после дешифрования
|
||||
// if (q && q.trim()) {
|
||||
// whereClause += " AND LOWER(n.content) LIKE ?";
|
||||
// params.push(`%${q.trim().toLowerCase()}%`);
|
||||
// }
|
||||
|
||||
// Поиск по тегу (регистронезависимый)
|
||||
// SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте
|
||||
@ -932,6 +936,7 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
|
||||
// Парсим JSON строки изображений и файлов
|
||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||
...row,
|
||||
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||||
}));
|
||||
@ -995,6 +1000,7 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
|
||||
// Парсим JSON строки изображений и файлов
|
||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||
...row,
|
||||
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||||
}));
|
||||
@ -1011,9 +1017,12 @@ app.post("/api/notes", requireApiAuth, (req, res) => {
|
||||
return res.status(400).json({ error: "Не все поля заполнены" });
|
||||
}
|
||||
|
||||
// Шифруем содержимое заметки перед сохранением
|
||||
const encryptedContent = encrypt(content);
|
||||
|
||||
const sql =
|
||||
"INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)";
|
||||
const params = [req.session.userId, content, date, time];
|
||||
const params = [req.session.userId, encryptedContent, date, time];
|
||||
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) {
|
||||
@ -1038,6 +1047,9 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
||||
return res.status(400).json({ error: "Содержание заметки обязательно" });
|
||||
}
|
||||
|
||||
// Шифруем содержимое заметки перед сохранением
|
||||
const encryptedContent = encrypt(content);
|
||||
|
||||
// Проверяем, что заметка принадлежит текущему пользователю
|
||||
const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?";
|
||||
db.get(checkSql, [id], (err, row) => {
|
||||
@ -1058,7 +1070,7 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
||||
const updateSql = skipTimestamp
|
||||
? "UPDATE notes SET content = ? WHERE id = ?"
|
||||
: "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||
const params = [content, id];
|
||||
const params = [encryptedContent, id];
|
||||
|
||||
db.run(updateSql, params, function (err) {
|
||||
if (err) {
|
||||
@ -1581,19 +1593,19 @@ app.get("/profile", requireAuth, (req, res) => {
|
||||
db.get(sql, [req.session.userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "profile.html"));
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
const accentColor = user?.accent_color || "#007bff";
|
||||
|
||||
// Читаем HTML файл
|
||||
fs.readFile(
|
||||
path.join(__dirname, "public", "profile.html"),
|
||||
path.join(__dirname, "public", "index.html"),
|
||||
"utf8",
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
console.error("Ошибка чтения файла profile.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "profile.html"));
|
||||
console.error("Ошибка чтения файла index.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
||||
@ -2001,6 +2013,7 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
|
||||
// Парсим JSON строки изображений и файлов
|
||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||
...row,
|
||||
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||||
}));
|
||||
@ -2271,19 +2284,19 @@ app.get("/settings", requireAuth, (req, res) => {
|
||||
db.get(sql, [req.session.userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "settings.html"));
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
const accentColor = user?.accent_color || "#007bff";
|
||||
|
||||
// Читаем HTML файл
|
||||
fs.readFile(
|
||||
path.join(__dirname, "public", "settings.html"),
|
||||
path.join(__dirname, "public", "index.html"),
|
||||
"utf8",
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
console.error("Ошибка чтения файла settings.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "settings.html"));
|
||||
console.error("Ошибка чтения файла index.html:", err.message);
|
||||
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
}
|
||||
|
||||
// Вставляем inline CSS с правильным цветом
|
||||
|
||||
140
backend/utils/encryption.js
Normal file
140
backend/utils/encryption.js
Normal file
@ -0,0 +1,140 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
// Ключ шифрования из переменных окружения или дефолтный (для разработки)
|
||||
// В продакшене ОБЯЗАТЕЛЬНО установите ENCRYPTION_KEY в .env файле!
|
||||
// Ключ должен быть строкой минимум 32 символа для AES-256
|
||||
const ENCRYPTION_KEY_STRING =
|
||||
process.env.ENCRYPTION_KEY || "default-key-change-in-production-min-32-chars";
|
||||
|
||||
// Проверка ключа при запуске
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isUsingDefaultKey = !process.env.ENCRYPTION_KEY;
|
||||
|
||||
// В продакшене требуем установленный ключ
|
||||
if (isProduction && isUsingDefaultKey) {
|
||||
console.error(
|
||||
"❌ ОШИБКА: ENCRYPTION_KEY не установлен в переменных окружения!"
|
||||
);
|
||||
console.error(
|
||||
"Установите ENCRYPTION_KEY в .env файле перед запуском в продакшене."
|
||||
);
|
||||
console.error("Пример: ENCRYPTION_KEY=ваш-случайный-ключ-минимум-32-символа");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Предупреждение в разработке
|
||||
if (!isProduction && isUsingDefaultKey) {
|
||||
console.warn("⚠️ ПРЕДУПРЕЖДЕНИЕ: Используется дефолтный ключ шифрования!");
|
||||
console.warn("Для безопасности установите ENCRYPTION_KEY в .env файле.");
|
||||
console.warn("Пример: ENCRYPTION_KEY=ваш-случайный-ключ-минимум-32-символа");
|
||||
}
|
||||
|
||||
// Преобразуем строку ключа в 32-байтовый ключ для AES-256
|
||||
const ENCRYPTION_KEY = crypto
|
||||
.createHash("sha256")
|
||||
.update(ENCRYPTION_KEY_STRING)
|
||||
.digest();
|
||||
|
||||
// Длина IV (Initialization Vector) для AES-256-GCM
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
// Метка аутентификации для проверки целостности данных
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Шифрует текст заметки
|
||||
* @param {string} text - Текст для шифрования
|
||||
* @returns {string} - Зашифрованный текст в формате base64:iv:authTag
|
||||
*/
|
||||
function encrypt(text) {
|
||||
if (!text || text.trim() === "") {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
// Генерируем случайный IV для каждой операции шифрования
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Создаем шифр с алгоритмом AES-256-GCM
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
|
||||
|
||||
// Шифруем текст
|
||||
let encrypted = cipher.update(text, "utf8", "base64");
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
// Получаем метку аутентификации для проверки целостности
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Возвращаем зашифрованные данные в формате: base64:iv:authTag
|
||||
return `${encrypted}:${iv.toString("base64")}:${authTag.toString(
|
||||
"base64"
|
||||
)}`;
|
||||
} catch (error) {
|
||||
console.error("Ошибка шифрования:", error);
|
||||
throw new Error("Ошибка шифрования данных");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дешифрует текст заметки
|
||||
* @param {string} encryptedText - Зашифрованный текст в формате base64:iv:authTag
|
||||
* @returns {string} - Расшифрованный текст
|
||||
*/
|
||||
function decrypt(encryptedText) {
|
||||
if (!encryptedText || encryptedText.trim() === "") {
|
||||
return encryptedText;
|
||||
}
|
||||
|
||||
// Проверяем формат зашифрованных данных
|
||||
// Если формат не соответствует ожидаемому, возможно это старая незашифрованная заметка
|
||||
const parts = encryptedText.split(":");
|
||||
if (parts.length !== 3) {
|
||||
// Если это не зашифрованный текст, возвращаем как есть (для обратной совместимости)
|
||||
console.warn("Обнаружен незашифрованный текст заметки");
|
||||
return encryptedText;
|
||||
}
|
||||
|
||||
try {
|
||||
const [encrypted, ivBase64, authTagBase64] = parts;
|
||||
|
||||
// Преобразуем IV и метку аутентификации из base64
|
||||
const iv = Buffer.from(ivBase64, "base64");
|
||||
const authTag = Buffer.from(authTagBase64, "base64");
|
||||
|
||||
// Создаем дешифратор с алгоритмом AES-256-GCM
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
|
||||
|
||||
// Устанавливаем метку аутентификации для проверки целостности
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// Дешифруем текст
|
||||
let decrypted = decipher.update(encrypted, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error("Ошибка дешифрования:", error);
|
||||
// Если не удалось дешифровать, возможно это старая заметка
|
||||
// Возвращаем оригинальный текст
|
||||
return encryptedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли текст зашифрованным
|
||||
* @param {string} text - Текст для проверки
|
||||
* @returns {boolean} - true если текст зашифрован
|
||||
*/
|
||||
function isEncrypted(text) {
|
||||
if (!text || text.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
const parts = text.split(":");
|
||||
return parts.length === 3;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isEncrypted,
|
||||
};
|
||||
@ -82,7 +82,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.hpmojmu6e28"
|
||||
"revision": "0.spl6nc4dqkg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@ -8,7 +8,7 @@ export const userApi = {
|
||||
},
|
||||
|
||||
updateProfile: async (
|
||||
profile: Partial<User> & {
|
||||
profile: Omit<Partial<User>, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled'> & {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
accent_color?: string;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
|
||||
@ -2,7 +2,6 @@ import React from "react";
|
||||
import { MiniCalendar } from "../calendar/MiniCalendar";
|
||||
import { SearchBar } from "../search/SearchBar";
|
||||
import { TagsFilter } from "../search/TagsFilter";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
import { Note } from "../../types/note";
|
||||
|
||||
interface SidebarProps {
|
||||
|
||||
@ -115,7 +115,14 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const buttons = [];
|
||||
const buttons: Array<{
|
||||
id: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
action?: () => void;
|
||||
}> = [];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -480,14 +480,15 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
const lines = text.split("\n");
|
||||
|
||||
// Определяем текущую строку
|
||||
let currentLineIndex = 0;
|
||||
// @ts-expect-error - переменная используется для определения текущей строки
|
||||
let _currentLineIndex = 0;
|
||||
let currentLineStart = 0;
|
||||
let currentLine = "";
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineLength = lines[i].length;
|
||||
if (currentLineStart + lineLength >= start) {
|
||||
currentLineIndex = i;
|
||||
_currentLineIndex = i;
|
||||
currentLine = lines[i];
|
||||
break;
|
||||
}
|
||||
@ -611,7 +612,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
const lineHeight = parseInt(styles.lineHeight) || 20;
|
||||
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||
const paddingLeft = parseInt(styles.paddingLeft) || 0;
|
||||
const fontSize = parseInt(styles.fontSize) || 14;
|
||||
// @ts-expect-error - переменная может использоваться в будущем
|
||||
const _fontSize = parseInt(styles.fontSize) || 14;
|
||||
const scrollTop = textarea.scrollTop;
|
||||
|
||||
// Вычисляем координаты начала выделения
|
||||
|
||||
@ -31,7 +31,7 @@ interface NoteItemProps {
|
||||
|
||||
export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
note,
|
||||
onDelete,
|
||||
onDelete: _onDelete,
|
||||
onPin,
|
||||
onArchive,
|
||||
onReload,
|
||||
@ -41,8 +41,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
||||
const [editImages, setEditImages] = useState<File[]>([]);
|
||||
const [editFiles, setEditFiles] = useState<File[]>([]);
|
||||
const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]);
|
||||
const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]);
|
||||
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]);
|
||||
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
|
||||
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
||||
@ -146,19 +146,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
setLocalPreviewMode(false);
|
||||
};
|
||||
|
||||
const handleDeleteExistingImage = (imageId: number) => {
|
||||
const handleDeleteExistingImage = (imageId: number | string) => {
|
||||
setDeletedImageIds([...deletedImageIds, imageId]);
|
||||
};
|
||||
|
||||
const handleDeleteExistingFile = (fileId: number) => {
|
||||
const handleDeleteExistingFile = (fileId: number | string) => {
|
||||
setDeletedFileIds([...deletedFileIds, fileId]);
|
||||
};
|
||||
|
||||
const handleRestoreImage = (imageId: number) => {
|
||||
const handleRestoreImage = (imageId: number | string) => {
|
||||
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
|
||||
};
|
||||
|
||||
const handleRestoreFile = (fileId: number) => {
|
||||
const handleRestoreFile = (fileId: number | string) => {
|
||||
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
|
||||
};
|
||||
|
||||
@ -576,7 +576,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
const lineHeight = parseInt(styles.lineHeight) || 20;
|
||||
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||
const paddingLeft = parseInt(styles.paddingLeft) || 0;
|
||||
const fontSize = parseInt(styles.fontSize) || 14;
|
||||
// @ts-expect-error - переменная может использоваться в будущем
|
||||
const _fontSize = parseInt(styles.fontSize) || 14;
|
||||
|
||||
// Более точный расчет ширины символа
|
||||
// Создаем временный элемент для измерения
|
||||
@ -699,14 +700,15 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
const lines = text.split("\n");
|
||||
|
||||
// Определяем текущую строку
|
||||
let currentLineIndex = 0;
|
||||
// @ts-expect-error - переменная используется для определения текущей строки
|
||||
let _currentLineIndex = 0;
|
||||
let currentLineStart = 0;
|
||||
let currentLine = "";
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineLength = lines[i].length;
|
||||
if (currentLineStart + lineLength >= start) {
|
||||
currentLineIndex = i;
|
||||
_currentLineIndex = i;
|
||||
currentLine = lines[i];
|
||||
break;
|
||||
}
|
||||
@ -1259,8 +1261,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
.map((image) => {
|
||||
const imageUrl = getImageUrl(
|
||||
image.file_path,
|
||||
note.id,
|
||||
image.id
|
||||
Number(note.id),
|
||||
Number(image.id)
|
||||
);
|
||||
return (
|
||||
<div key={image.id} className="image-preview-item">
|
||||
@ -1298,8 +1300,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
.map((image) => {
|
||||
const imageUrl = getImageUrl(
|
||||
image.file_path,
|
||||
note.id,
|
||||
image.id
|
||||
Number(note.id),
|
||||
Number(image.id)
|
||||
);
|
||||
return (
|
||||
<div key={image.id} className="image-preview-item">
|
||||
@ -1336,11 +1338,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
{note.files
|
||||
.filter((file) => !deletedFileIds.includes(file.id))
|
||||
.map((file) => {
|
||||
const fileUrl = getFileUrl(
|
||||
file.file_path,
|
||||
note.id,
|
||||
file.id
|
||||
);
|
||||
// fileUrl не используется в этом контексте
|
||||
return (
|
||||
<div key={file.id} className="file-preview-item">
|
||||
<Icon
|
||||
@ -1476,8 +1474,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
{note.images.map((image) => {
|
||||
const imageUrl = getImageUrl(
|
||||
image.file_path,
|
||||
note.id,
|
||||
image.id
|
||||
Number(note.id),
|
||||
Number(image.id)
|
||||
);
|
||||
return (
|
||||
<div key={image.id} className="note-image-item">
|
||||
@ -1499,7 +1497,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
{note.files && note.files.length > 0 && (
|
||||
<div className="note-files-container">
|
||||
{note.files.map((file) => {
|
||||
const fileUrl = getFileUrl(file.file_path, note.id, file.id);
|
||||
const fileUrl = getFileUrl(file.file_path, Number(note.id), Number(file.id));
|
||||
return (
|
||||
<div key={file.id} className="note-file-item">
|
||||
<a
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { NoteItem } from "./NoteItem";
|
||||
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||
@ -10,7 +10,7 @@ export interface NotesListRef {
|
||||
reloadNotes: () => void;
|
||||
}
|
||||
|
||||
export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
||||
export const NotesList = forwardRef<NotesListRef>((_props, ref) => {
|
||||
const notes = useAppSelector((state) => state.notes.notes);
|
||||
const userId = useAppSelector((state) => state.auth.userId);
|
||||
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||
|
||||
@ -6,7 +6,7 @@ import { setSearchQuery } from "../../store/slices/notesSlice";
|
||||
export const SearchBar: React.FC = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
const dispatch = useAppDispatch();
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce для поиска
|
||||
|
||||
@ -10,7 +10,10 @@ export const useNotification = () => {
|
||||
message: string,
|
||||
type: "info" | "success" | "error" | "warning" = "info"
|
||||
) => {
|
||||
const id = dispatch(addNotification({ message, type })).payload.id;
|
||||
const id = `notification-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
dispatch(addNotification({ message, type, id }));
|
||||
setTimeout(() => {
|
||||
dispatch(removeNotification(id));
|
||||
}, 4000);
|
||||
|
||||
@ -17,7 +17,8 @@ const ProfilePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { showNotification } = useNotification();
|
||||
const user = useAppSelector((state) => state.profile.user);
|
||||
// @ts-expect-error - переменная может использоваться в будущем
|
||||
const _user = useAppSelector((state) => state.profile.user);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
@ -13,7 +13,6 @@ import { setAccentColor } from "../utils/colorUtils";
|
||||
import { useNotification } from "../hooks/useNotification";
|
||||
import { Modal } from "../components/common/Modal";
|
||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||
import { formatDateFromTimestamp } from "../utils/dateFormat";
|
||||
import { parseMarkdown } from "../utils/markdown";
|
||||
import { dbManager } from "../utils/indexedDB";
|
||||
import { syncService } from "../services/syncService";
|
||||
@ -25,8 +24,11 @@ const SettingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { showNotification } = useNotification();
|
||||
const user = useAppSelector((state) => state.profile.user);
|
||||
const accentColor = useAppSelector((state) => state.ui.accentColor);
|
||||
// @ts-expect-error - переменная может использоваться в будущем
|
||||
const _user = useAppSelector((state) => state.profile.user);
|
||||
const userId = useAppSelector((state) => state.auth.userId);
|
||||
// @ts-expect-error - переменная может использоваться в будущем
|
||||
const _accentColor = useAppSelector((state) => state.ui.accentColor);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>(() => {
|
||||
// Восстанавливаем активную вкладку из localStorage при инициализации
|
||||
@ -280,9 +282,9 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreNote = async (id: number) => {
|
||||
const handleRestoreNote = async (id: number | string) => {
|
||||
try {
|
||||
await notesApi.unarchive(id);
|
||||
await notesApi.unarchive(Number(id));
|
||||
await loadArchivedNotes();
|
||||
showNotification("Заметка восстановлена!", "success");
|
||||
} catch (error: any) {
|
||||
@ -294,9 +296,9 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePermanent = async (id: number) => {
|
||||
const handleDeletePermanent = async (id: number | string) => {
|
||||
try {
|
||||
await notesApi.deleteArchived(id);
|
||||
await notesApi.deleteArchived(Number(id));
|
||||
await loadArchivedNotes();
|
||||
showNotification("Заметка удалена окончательно", "success");
|
||||
} catch (error: any) {
|
||||
@ -427,7 +429,6 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
// Загружаем версию из IndexedDB
|
||||
try {
|
||||
const userId = user?.id;
|
||||
const localVer = userId
|
||||
? await dbManager.getDataVersionByUserId(userId)
|
||||
: await dbManager.getDataVersion();
|
||||
@ -872,14 +873,14 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="archived-note-actions">
|
||||
<button
|
||||
className="btn-restore"
|
||||
onClick={() => handleRestoreNote(note.id)}
|
||||
onClick={() => handleRestoreNote(Number(note.id))}
|
||||
title="Восстановить"
|
||||
>
|
||||
<Icon icon="mdi:restore" /> Восстановить
|
||||
</button>
|
||||
<button
|
||||
className="btn-delete-permanent"
|
||||
onClick={() => handleDeletePermanent(note.id)}
|
||||
onClick={() => handleDeletePermanent(Number(note.id))}
|
||||
title="Удалить навсегда"
|
||||
>
|
||||
<Icon icon="mdi:delete-forever" /> Удалить
|
||||
|
||||
@ -5,13 +5,11 @@ import { Note, NoteImage, NoteFile } from '../types/note';
|
||||
import { store } from '../store/index';
|
||||
import {
|
||||
setSyncStatus,
|
||||
removeNotification,
|
||||
addNotification,
|
||||
} from '../store/slices/uiSlice';
|
||||
import {
|
||||
updateNote,
|
||||
setPendingSyncCount,
|
||||
setOfflineMode,
|
||||
} from '../store/slices/notesSlice';
|
||||
import { SyncQueueItem } from '../types/note';
|
||||
|
||||
@ -20,7 +18,7 @@ const RETRY_DELAY_MS = 5000;
|
||||
|
||||
class SyncService {
|
||||
private isSyncing = false;
|
||||
private syncTimer: NodeJS.Timeout | null = null;
|
||||
private syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private listeners: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
@ -444,7 +442,7 @@ class SyncService {
|
||||
*/
|
||||
private async updateImageReferences(
|
||||
localNote: Note,
|
||||
serverNote: Note
|
||||
_serverNote: Note
|
||||
): Promise<NoteImage[]> {
|
||||
// Если нет изображений с base64, возвращаем как есть
|
||||
const hasBase64Images = localNote.images.some((img) => img.base64Data);
|
||||
@ -461,7 +459,7 @@ class SyncService {
|
||||
*/
|
||||
private async updateFileReferences(
|
||||
localNote: Note,
|
||||
serverNote: Note
|
||||
_serverNote: Note
|
||||
): Promise<NoteFile[]> {
|
||||
// Если нет файлов с base64, возвращаем как есть
|
||||
const hasBase64Files = localNote.files.some((file) => file.base64Data);
|
||||
|
||||
@ -50,12 +50,13 @@ const uiSlice = createSlice({
|
||||
},
|
||||
addNotification: (
|
||||
state,
|
||||
action: PayloadAction<Omit<Notification, "id">>
|
||||
action: PayloadAction<Omit<Notification, "id"> & { id?: string }>
|
||||
) => {
|
||||
const id = `notification-${Date.now()}-${Math.random()
|
||||
const id = action.payload.id || `notification-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
state.notifications.push({ ...action.payload, id });
|
||||
const { id: _, ...notificationData } = action.payload;
|
||||
state.notifications.push({ ...notificationData, id });
|
||||
},
|
||||
removeNotification: (state, action: PayloadAction<string>) => {
|
||||
state.notifications = state.notifications.filter(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user