Обновлен файл .gitignore для исключения новых временных и кэшированных файлов, а также добавлены переменные окружения для разработки. Обновлены файлы документации с добавлением информации о ключе шифрования. Внесены изменения в серверный код для поддержки шифрования и дешифрования содержимого заметок. Обновлены компоненты для улучшения работы с заметками и их отображением. Оптимизированы стили и скрипты для улучшения пользовательского интерфейса.
This commit is contained in:
parent
82094e46b5
commit
e6ebf2cbff
69
.gitignore
vendored
69
.gitignore
vendored
@ -10,17 +10,30 @@ lerna-debug.log*
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
backend/node_modules
|
backend/node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
|
build
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
.vite
|
||||||
|
.vite/*
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
backend/.env
|
backend/.env
|
||||||
|
backend/.env.local
|
||||||
|
backend/.env.*.local
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
backend/database/*.db
|
backend/database/*.db
|
||||||
@ -31,11 +44,29 @@ backend/database/*.db-wal
|
|||||||
backend/public/uploads/*
|
backend/public/uploads/*
|
||||||
!backend/public/uploads/.gitkeep
|
!backend/public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
.coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.turbo
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
*.swp
|
||||||
|
*.swo
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
@ -47,4 +78,40 @@ public/sw.js.map
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
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
|
PORT=3001
|
||||||
SESSION_SECRET=your-secret-key-here
|
SESSION_SECRET=your-secret-key-here
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
ENCRYPTION_KEY=your-encryption-key-min-32-chars
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📍 Порты
|
## 📍 Порты
|
||||||
|
|||||||
@ -92,8 +92,11 @@ npm run lint
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
SESSION_SECRET=замените-на-свой-секретный-ключ
|
SESSION_SECRET=замените-на-свой-секретный-ключ
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
ENCRYPTION_KEY=замените-на-свой-ключ-минимум-32-символа
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Важно:** `ENCRYPTION_KEY` используется для шифрования заметок в базе данных. Используйте надежный случайный ключ минимум 32 символа!
|
||||||
|
|
||||||
### 2. Первый пользователь
|
### 2. Первый пользователь
|
||||||
|
|
||||||
1. Откройте http://localhost:5173
|
1. Откройте http://localhost:5173
|
||||||
|
|||||||
@ -171,8 +171,14 @@ npm run lint
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
SESSION_SECRET=your-secret-key-here
|
SESSION_SECRET=your-secret-key-here
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
ENCRYPTION_KEY=your-encryption-key-min-32-chars
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- `ENCRYPTION_KEY` - ключ для шифрования заметок в базе данных (минимум 32 символа)
|
||||||
|
- В продакшене ОБЯЗАТЕЛЬНО используйте надежный случайный ключ
|
||||||
|
- Не используйте дефолтный ключ в продакшене!
|
||||||
|
|
||||||
### AI настройки
|
### AI настройки
|
||||||
|
|
||||||
Настройки AI выполняются через интерфейс приложения в разделе "Настройки".
|
Настройки AI выполняются через интерфейс приложения в разделе "Настройки".
|
||||||
|
|||||||
@ -16,8 +16,14 @@ npm install
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
SESSION_SECRET=your-secret-key-here-change-in-production
|
SESSION_SECRET=your-secret-key-here-change-in-production
|
||||||
NODE_ENV=development
|
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 -->
|
<!-- Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<script type="module" crossorigin src="/assets/index-CRKRzJj1.js"></script>
|
<script type="module" crossorigin src="/assets/index-42KwbWCP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-QEK5TGz3.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
|
||||||
<link rel="manifest" href="/manifest.webmanifest"></head>
|
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||||
<body>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -9,27 +9,10 @@
|
|||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"lang": "ru",
|
"lang": "ru",
|
||||||
"id": "/",
|
|
||||||
"categories": ["productivity", "utilities"],
|
"categories": ["productivity", "utilities"],
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"display_override": ["window-controls-overlay", "standalone"],
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
"dir": "ltr",
|
"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": [
|
"screenshots": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512x512.png",
|
"src": "/icons/icon-512x512.png",
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
// Загружаем .env файл ПЕРВЫМ, до всех остальных импортов
|
||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const sqlite3 = require("sqlite3").verbose();
|
const sqlite3 = require("sqlite3").verbose();
|
||||||
const bcrypt = require("bcryptjs");
|
const bcrypt = require("bcryptjs");
|
||||||
@ -11,7 +14,7 @@ const multer = require("multer");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const https = require("https");
|
const https = require("https");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
require("dotenv").config();
|
const { encrypt, decrypt } = require("./utils/encryption");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@ -659,7 +662,7 @@ app.get("/register", (req, res) => {
|
|||||||
if (req.session.authenticated) {
|
if (req.session.authenticated) {
|
||||||
return res.redirect("/notes");
|
return res.redirect("/notes");
|
||||||
}
|
}
|
||||||
res.sendFile(path.join(__dirname, "public", "register.html"));
|
res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// API регистрации
|
// API регистрации
|
||||||
@ -815,19 +818,19 @@ app.get("/notes", requireAuth, (req, res) => {
|
|||||||
db.get(sql, [req.session.userId], (err, user) => {
|
db.get(sql, [req.session.userId], (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
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";
|
const accentColor = user?.accent_color || "#007bff";
|
||||||
|
|
||||||
// Читаем HTML файл
|
// Читаем HTML файл
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
path.join(__dirname, "public", "notes.html"),
|
path.join(__dirname, "public", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
(err, html) => {
|
(err, html) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка чтения файла notes.html:", err.message);
|
console.error("Ошибка чтения файла index.html:", err.message);
|
||||||
return res.sendFile(path.join(__dirname, "public", "notes.html"));
|
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
// Вставляем 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 whereClause = "WHERE n.user_id = ? AND n.is_archived = 0";
|
||||||
let params = [req.session.userId];
|
let params = [req.session.userId];
|
||||||
|
|
||||||
// Поиск по тексту (регистронезависимый)
|
// Поиск по тексту - убран из SQL, так как зашифрованный текст нельзя искать
|
||||||
if (q && q.trim()) {
|
// Поиск будет выполняться на клиенте после дешифрования
|
||||||
whereClause += " AND LOWER(n.content) LIKE ?";
|
// if (q && q.trim()) {
|
||||||
params.push(`%${q.trim().toLowerCase()}%`);
|
// whereClause += " AND LOWER(n.content) LIKE ?";
|
||||||
}
|
// params.push(`%${q.trim().toLowerCase()}%`);
|
||||||
|
// }
|
||||||
|
|
||||||
// Поиск по тегу (регистронезависимый)
|
// Поиск по тегу (регистронезависимый)
|
||||||
// SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте
|
// SQLite LOWER() плохо работает с кириллицей, поэтому фильтрация по тегу выполняется на клиенте
|
||||||
@ -932,6 +936,7 @@ app.get("/api/notes/search", requireApiAuth, (req, res) => {
|
|||||||
// Парсим JSON строки изображений и файлов
|
// Парсим JSON строки изображений и файлов
|
||||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||||||
}));
|
}));
|
||||||
@ -995,6 +1000,7 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
|
|||||||
// Парсим JSON строки изображений и файлов
|
// Парсим JSON строки изображений и файлов
|
||||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
||||||
}));
|
}));
|
||||||
@ -1011,9 +1017,12 @@ app.post("/api/notes", requireApiAuth, (req, res) => {
|
|||||||
return res.status(400).json({ error: "Не все поля заполнены" });
|
return res.status(400).json({ error: "Не все поля заполнены" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Шифруем содержимое заметки перед сохранением
|
||||||
|
const encryptedContent = encrypt(content);
|
||||||
|
|
||||||
const sql =
|
const sql =
|
||||||
"INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)";
|
"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) {
|
db.run(sql, params, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -1038,6 +1047,9 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
|||||||
return res.status(400).json({ error: "Содержание заметки обязательно" });
|
return res.status(400).json({ error: "Содержание заметки обязательно" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Шифруем содержимое заметки перед сохранением
|
||||||
|
const encryptedContent = encrypt(content);
|
||||||
|
|
||||||
// Проверяем, что заметка принадлежит текущему пользователю
|
// Проверяем, что заметка принадлежит текущему пользователю
|
||||||
const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?";
|
const checkSql = "SELECT user_id, date, time FROM notes WHERE id = ?";
|
||||||
db.get(checkSql, [id], (err, row) => {
|
db.get(checkSql, [id], (err, row) => {
|
||||||
@ -1058,7 +1070,7 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
|||||||
const updateSql = skipTimestamp
|
const updateSql = skipTimestamp
|
||||||
? "UPDATE notes SET content = ? WHERE id = ?"
|
? "UPDATE notes SET content = ? WHERE id = ?"
|
||||||
: "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP 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) {
|
db.run(updateSql, params, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -1581,19 +1593,19 @@ app.get("/profile", requireAuth, (req, res) => {
|
|||||||
db.get(sql, [req.session.userId], (err, user) => {
|
db.get(sql, [req.session.userId], (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
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";
|
const accentColor = user?.accent_color || "#007bff";
|
||||||
|
|
||||||
// Читаем HTML файл
|
// Читаем HTML файл
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
path.join(__dirname, "public", "profile.html"),
|
path.join(__dirname, "public", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
(err, html) => {
|
(err, html) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка чтения файла profile.html:", err.message);
|
console.error("Ошибка чтения файла index.html:", err.message);
|
||||||
return res.sendFile(path.join(__dirname, "public", "profile.html"));
|
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
// Вставляем inline CSS с правильным цветом в самое начало head для предотвращения FOUC
|
||||||
@ -2001,6 +2013,7 @@ app.get("/api/notes/archived", requireApiAuth, (req, res) => {
|
|||||||
// Парсим JSON строки изображений и файлов
|
// Парсим JSON строки изображений и файлов
|
||||||
const notesWithImagesAndFiles = rows.map((row) => ({
|
const notesWithImagesAndFiles = rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
content: decrypt(row.content), // Дешифруем содержимое заметки
|
||||||
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
images: row.images === "[]" ? [] : JSON.parse(row.images),
|
||||||
files: row.files === "[]" ? [] : JSON.parse(row.files),
|
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) => {
|
db.get(sql, [req.session.userId], (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка получения цвета пользователя:", err.message);
|
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";
|
const accentColor = user?.accent_color || "#007bff";
|
||||||
|
|
||||||
// Читаем HTML файл
|
// Читаем HTML файл
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
path.join(__dirname, "public", "settings.html"),
|
path.join(__dirname, "public", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
(err, html) => {
|
(err, html) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка чтения файла settings.html:", err.message);
|
console.error("Ошибка чтения файла index.html:", err.message);
|
||||||
return res.sendFile(path.join(__dirname, "public", "settings.html"));
|
return res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вставляем inline CSS с правильным цветом
|
// Вставляем 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"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.hpmojmu6e28"
|
"revision": "0.spl6nc4dqkg"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export const userApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateProfile: async (
|
updateProfile: async (
|
||||||
profile: Partial<User> & {
|
profile: Omit<Partial<User>, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled'> & {
|
||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
accent_color?: string;
|
accent_color?: string;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import { MiniCalendar } from "../calendar/MiniCalendar";
|
import { MiniCalendar } from "../calendar/MiniCalendar";
|
||||||
import { SearchBar } from "../search/SearchBar";
|
import { SearchBar } from "../search/SearchBar";
|
||||||
import { TagsFilter } from "../search/TagsFilter";
|
import { TagsFilter } from "../search/TagsFilter";
|
||||||
import { useAppSelector } from "../../store/hooks";
|
|
||||||
import { Note } from "../../types/note";
|
import { Note } from "../../types/note";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|||||||
@ -115,7 +115,14 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isDragging]);
|
}, [isDragging]);
|
||||||
|
|
||||||
const buttons = [];
|
const buttons: Array<{
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
action?: () => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -480,14 +480,15 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
|
|
||||||
// Определяем текущую строку
|
// Определяем текущую строку
|
||||||
let currentLineIndex = 0;
|
// @ts-expect-error - переменная используется для определения текущей строки
|
||||||
|
let _currentLineIndex = 0;
|
||||||
let currentLineStart = 0;
|
let currentLineStart = 0;
|
||||||
let currentLine = "";
|
let currentLine = "";
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const lineLength = lines[i].length;
|
const lineLength = lines[i].length;
|
||||||
if (currentLineStart + lineLength >= start) {
|
if (currentLineStart + lineLength >= start) {
|
||||||
currentLineIndex = i;
|
_currentLineIndex = i;
|
||||||
currentLine = lines[i];
|
currentLine = lines[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -611,7 +612,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
const lineHeight = parseInt(styles.lineHeight) || 20;
|
const lineHeight = parseInt(styles.lineHeight) || 20;
|
||||||
const paddingTop = parseInt(styles.paddingTop) || 0;
|
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||||
const paddingLeft = parseInt(styles.paddingLeft) || 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;
|
const scrollTop = textarea.scrollTop;
|
||||||
|
|
||||||
// Вычисляем координаты начала выделения
|
// Вычисляем координаты начала выделения
|
||||||
|
|||||||
@ -31,7 +31,7 @@ interface NoteItemProps {
|
|||||||
|
|
||||||
export const NoteItem: React.FC<NoteItemProps> = ({
|
export const NoteItem: React.FC<NoteItemProps> = ({
|
||||||
note,
|
note,
|
||||||
onDelete,
|
onDelete: _onDelete,
|
||||||
onPin,
|
onPin,
|
||||||
onArchive,
|
onArchive,
|
||||||
onReload,
|
onReload,
|
||||||
@ -41,8 +41,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
||||||
const [editImages, setEditImages] = useState<File[]>([]);
|
const [editImages, setEditImages] = useState<File[]>([]);
|
||||||
const [editFiles, setEditFiles] = useState<File[]>([]);
|
const [editFiles, setEditFiles] = useState<File[]>([]);
|
||||||
const [deletedImageIds, setDeletedImageIds] = useState<number[]>([]);
|
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]);
|
||||||
const [deletedFileIds, setDeletedFileIds] = useState<number[]>([]);
|
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
|
||||||
const [isAiLoading, setIsAiLoading] = useState(false);
|
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||||
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
||||||
@ -146,19 +146,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
setLocalPreviewMode(false);
|
setLocalPreviewMode(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteExistingImage = (imageId: number) => {
|
const handleDeleteExistingImage = (imageId: number | string) => {
|
||||||
setDeletedImageIds([...deletedImageIds, imageId]);
|
setDeletedImageIds([...deletedImageIds, imageId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteExistingFile = (fileId: number) => {
|
const handleDeleteExistingFile = (fileId: number | string) => {
|
||||||
setDeletedFileIds([...deletedFileIds, fileId]);
|
setDeletedFileIds([...deletedFileIds, fileId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreImage = (imageId: number) => {
|
const handleRestoreImage = (imageId: number | string) => {
|
||||||
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
|
setDeletedImageIds(deletedImageIds.filter((id) => id !== imageId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreFile = (fileId: number) => {
|
const handleRestoreFile = (fileId: number | string) => {
|
||||||
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
|
setDeletedFileIds(deletedFileIds.filter((id) => id !== fileId));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -576,7 +576,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
const lineHeight = parseInt(styles.lineHeight) || 20;
|
const lineHeight = parseInt(styles.lineHeight) || 20;
|
||||||
const paddingTop = parseInt(styles.paddingTop) || 0;
|
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||||
const paddingLeft = parseInt(styles.paddingLeft) || 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");
|
const lines = text.split("\n");
|
||||||
|
|
||||||
// Определяем текущую строку
|
// Определяем текущую строку
|
||||||
let currentLineIndex = 0;
|
// @ts-expect-error - переменная используется для определения текущей строки
|
||||||
|
let _currentLineIndex = 0;
|
||||||
let currentLineStart = 0;
|
let currentLineStart = 0;
|
||||||
let currentLine = "";
|
let currentLine = "";
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const lineLength = lines[i].length;
|
const lineLength = lines[i].length;
|
||||||
if (currentLineStart + lineLength >= start) {
|
if (currentLineStart + lineLength >= start) {
|
||||||
currentLineIndex = i;
|
_currentLineIndex = i;
|
||||||
currentLine = lines[i];
|
currentLine = lines[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1259,8 +1261,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
.map((image) => {
|
.map((image) => {
|
||||||
const imageUrl = getImageUrl(
|
const imageUrl = getImageUrl(
|
||||||
image.file_path,
|
image.file_path,
|
||||||
note.id,
|
Number(note.id),
|
||||||
image.id
|
Number(image.id)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={image.id} className="image-preview-item">
|
<div key={image.id} className="image-preview-item">
|
||||||
@ -1298,8 +1300,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
.map((image) => {
|
.map((image) => {
|
||||||
const imageUrl = getImageUrl(
|
const imageUrl = getImageUrl(
|
||||||
image.file_path,
|
image.file_path,
|
||||||
note.id,
|
Number(note.id),
|
||||||
image.id
|
Number(image.id)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={image.id} className="image-preview-item">
|
<div key={image.id} className="image-preview-item">
|
||||||
@ -1336,11 +1338,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
{note.files
|
{note.files
|
||||||
.filter((file) => !deletedFileIds.includes(file.id))
|
.filter((file) => !deletedFileIds.includes(file.id))
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const fileUrl = getFileUrl(
|
// fileUrl не используется в этом контексте
|
||||||
file.file_path,
|
|
||||||
note.id,
|
|
||||||
file.id
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div key={file.id} className="file-preview-item">
|
<div key={file.id} className="file-preview-item">
|
||||||
<Icon
|
<Icon
|
||||||
@ -1476,8 +1474,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
{note.images.map((image) => {
|
{note.images.map((image) => {
|
||||||
const imageUrl = getImageUrl(
|
const imageUrl = getImageUrl(
|
||||||
image.file_path,
|
image.file_path,
|
||||||
note.id,
|
Number(note.id),
|
||||||
image.id
|
Number(image.id)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={image.id} className="note-image-item">
|
<div key={image.id} className="note-image-item">
|
||||||
@ -1499,7 +1497,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
{note.files && note.files.length > 0 && (
|
{note.files && note.files.length > 0 && (
|
||||||
<div className="note-files-container">
|
<div className="note-files-container">
|
||||||
{note.files.map((file) => {
|
{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 (
|
return (
|
||||||
<div key={file.id} className="note-file-item">
|
<div key={file.id} className="note-file-item">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useImperativeHandle, forwardRef } from "react";
|
import { useEffect, useImperativeHandle, forwardRef } from "react";
|
||||||
import { NoteItem } from "./NoteItem";
|
import { NoteItem } from "./NoteItem";
|
||||||
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
import { offlineNotesApi } from "../../api/offlineNotesApi";
|
||||||
@ -10,7 +10,7 @@ export interface NotesListRef {
|
|||||||
reloadNotes: () => void;
|
reloadNotes: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotesList = forwardRef<NotesListRef>((props, ref) => {
|
export const NotesList = forwardRef<NotesListRef>((_props, 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);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { setSearchQuery } from "../../store/slices/notesSlice";
|
|||||||
export const SearchBar: React.FC = () => {
|
export const SearchBar: React.FC = () => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Debounce для поиска
|
// Debounce для поиска
|
||||||
|
|||||||
@ -10,7 +10,10 @@ export const useNotification = () => {
|
|||||||
message: string,
|
message: string,
|
||||||
type: "info" | "success" | "error" | "warning" = "info"
|
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(() => {
|
setTimeout(() => {
|
||||||
dispatch(removeNotification(id));
|
dispatch(removeNotification(id));
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|||||||
@ -17,7 +17,8 @@ const ProfilePage: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { showNotification } = useNotification();
|
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 [username, setUsername] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { setAccentColor } from "../utils/colorUtils";
|
|||||||
import { useNotification } from "../hooks/useNotification";
|
import { useNotification } from "../hooks/useNotification";
|
||||||
import { Modal } from "../components/common/Modal";
|
import { Modal } from "../components/common/Modal";
|
||||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
import { formatDateFromTimestamp } from "../utils/dateFormat";
|
|
||||||
import { parseMarkdown } from "../utils/markdown";
|
import { parseMarkdown } from "../utils/markdown";
|
||||||
import { dbManager } from "../utils/indexedDB";
|
import { dbManager } from "../utils/indexedDB";
|
||||||
import { syncService } from "../services/syncService";
|
import { syncService } from "../services/syncService";
|
||||||
@ -25,8 +24,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const user = useAppSelector((state) => state.profile.user);
|
// @ts-expect-error - переменная может использоваться в будущем
|
||||||
const accentColor = useAppSelector((state) => state.ui.accentColor);
|
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>(() => {
|
const [activeTab, setActiveTab] = useState<SettingsTab>(() => {
|
||||||
// Восстанавливаем активную вкладку из localStorage при инициализации
|
// Восстанавливаем активную вкладку из localStorage при инициализации
|
||||||
@ -280,9 +282,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreNote = async (id: number) => {
|
const handleRestoreNote = async (id: number | string) => {
|
||||||
try {
|
try {
|
||||||
await notesApi.unarchive(id);
|
await notesApi.unarchive(Number(id));
|
||||||
await loadArchivedNotes();
|
await loadArchivedNotes();
|
||||||
showNotification("Заметка восстановлена!", "success");
|
showNotification("Заметка восстановлена!", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -294,9 +296,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePermanent = async (id: number) => {
|
const handleDeletePermanent = async (id: number | string) => {
|
||||||
try {
|
try {
|
||||||
await notesApi.deleteArchived(id);
|
await notesApi.deleteArchived(Number(id));
|
||||||
await loadArchivedNotes();
|
await loadArchivedNotes();
|
||||||
showNotification("Заметка удалена окончательно", "success");
|
showNotification("Заметка удалена окончательно", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -427,7 +429,6 @@ const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Загружаем версию из IndexedDB
|
// Загружаем версию из IndexedDB
|
||||||
try {
|
try {
|
||||||
const userId = user?.id;
|
|
||||||
const localVer = userId
|
const localVer = userId
|
||||||
? await dbManager.getDataVersionByUserId(userId)
|
? await dbManager.getDataVersionByUserId(userId)
|
||||||
: await dbManager.getDataVersion();
|
: await dbManager.getDataVersion();
|
||||||
@ -872,14 +873,14 @@ const SettingsPage: React.FC = () => {
|
|||||||
<div className="archived-note-actions">
|
<div className="archived-note-actions">
|
||||||
<button
|
<button
|
||||||
className="btn-restore"
|
className="btn-restore"
|
||||||
onClick={() => handleRestoreNote(note.id)}
|
onClick={() => handleRestoreNote(Number(note.id))}
|
||||||
title="Восстановить"
|
title="Восстановить"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:restore" /> Восстановить
|
<Icon icon="mdi:restore" /> Восстановить
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-delete-permanent"
|
className="btn-delete-permanent"
|
||||||
onClick={() => handleDeletePermanent(note.id)}
|
onClick={() => handleDeletePermanent(Number(note.id))}
|
||||||
title="Удалить навсегда"
|
title="Удалить навсегда"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:delete-forever" /> Удалить
|
<Icon icon="mdi:delete-forever" /> Удалить
|
||||||
|
|||||||
@ -5,13 +5,11 @@ import { Note, NoteImage, NoteFile } from '../types/note';
|
|||||||
import { store } from '../store/index';
|
import { store } from '../store/index';
|
||||||
import {
|
import {
|
||||||
setSyncStatus,
|
setSyncStatus,
|
||||||
removeNotification,
|
|
||||||
addNotification,
|
addNotification,
|
||||||
} from '../store/slices/uiSlice';
|
} from '../store/slices/uiSlice';
|
||||||
import {
|
import {
|
||||||
updateNote,
|
updateNote,
|
||||||
setPendingSyncCount,
|
setPendingSyncCount,
|
||||||
setOfflineMode,
|
|
||||||
} from '../store/slices/notesSlice';
|
} from '../store/slices/notesSlice';
|
||||||
import { SyncQueueItem } from '../types/note';
|
import { SyncQueueItem } from '../types/note';
|
||||||
|
|
||||||
@ -20,7 +18,7 @@ const RETRY_DELAY_MS = 5000;
|
|||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
private isSyncing = false;
|
private isSyncing = false;
|
||||||
private syncTimer: NodeJS.Timeout | null = null;
|
private syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private listeners: Array<() => void> = [];
|
private listeners: Array<() => void> = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -444,7 +442,7 @@ class SyncService {
|
|||||||
*/
|
*/
|
||||||
private async updateImageReferences(
|
private async updateImageReferences(
|
||||||
localNote: Note,
|
localNote: Note,
|
||||||
serverNote: Note
|
_serverNote: Note
|
||||||
): Promise<NoteImage[]> {
|
): Promise<NoteImage[]> {
|
||||||
// Если нет изображений с base64, возвращаем как есть
|
// Если нет изображений с base64, возвращаем как есть
|
||||||
const hasBase64Images = localNote.images.some((img) => img.base64Data);
|
const hasBase64Images = localNote.images.some((img) => img.base64Data);
|
||||||
@ -461,7 +459,7 @@ class SyncService {
|
|||||||
*/
|
*/
|
||||||
private async updateFileReferences(
|
private async updateFileReferences(
|
||||||
localNote: Note,
|
localNote: Note,
|
||||||
serverNote: Note
|
_serverNote: Note
|
||||||
): Promise<NoteFile[]> {
|
): Promise<NoteFile[]> {
|
||||||
// Если нет файлов с base64, возвращаем как есть
|
// Если нет файлов с base64, возвращаем как есть
|
||||||
const hasBase64Files = localNote.files.some((file) => file.base64Data);
|
const hasBase64Files = localNote.files.some((file) => file.base64Data);
|
||||||
|
|||||||
@ -50,12 +50,13 @@ const uiSlice = createSlice({
|
|||||||
},
|
},
|
||||||
addNotification: (
|
addNotification: (
|
||||||
state,
|
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)
|
.toString(36)
|
||||||
.substr(2, 9)}`;
|
.substr(2, 9)}`;
|
||||||
state.notifications.push({ ...action.payload, id });
|
const { id: _, ...notificationData } = action.payload;
|
||||||
|
state.notifications.push({ ...notificationData, id });
|
||||||
},
|
},
|
||||||
removeNotification: (state, action: PayloadAction<string>) => {
|
removeNotification: (state, action: PayloadAction<string>) => {
|
||||||
state.notifications = state.notifications.filter(
|
state.notifications = state.notifications.filter(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user