Переезд на React в новый репозитторий
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
backend/node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
backend/.env
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
backend/database/*.db
|
||||||
|
backend/database/*.db-shm
|
||||||
|
backend/database/*.db-wal
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
backend/public/uploads/*
|
||||||
|
!backend/public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# PWA
|
||||||
|
public/sw.js.map
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
154
CHANGES.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# Список изменений - NoteJS React
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
Проект реорганизован для независимой работы фронтенда и бэкенда:
|
||||||
|
|
||||||
|
### ✅ Backend (Node.js + Express)
|
||||||
|
|
||||||
|
**Расположение:** `backend/`
|
||||||
|
|
||||||
|
**Что сделано:**
|
||||||
|
|
||||||
|
- ✅ Скопирован `server.js` из корня проекта
|
||||||
|
- ✅ Скопированы `package.json` и `package-lock.json`
|
||||||
|
- ✅ Изменён порт с `3000` на `3001` (чтобы не конфликтовать со старым приложением)
|
||||||
|
- ✅ Установлены все зависимости (`npm install`)
|
||||||
|
- ✅ Созданы папки:
|
||||||
|
- `database/` - для SQLite баз данных
|
||||||
|
- `public/uploads/` - для загруженных файлов
|
||||||
|
- ✅ Создан `.gitignore` для исключения ненужных файлов
|
||||||
|
- ✅ Создан `README.md` с инструкциями
|
||||||
|
|
||||||
|
**Запуск:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Адрес:** http://localhost:3001
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Frontend (React + TypeScript)
|
||||||
|
|
||||||
|
**Расположение:** `src/`
|
||||||
|
|
||||||
|
**Что сделано:**
|
||||||
|
|
||||||
|
- ✅ Обновлён `vite.config.ts`:
|
||||||
|
- Порт изменён на `5173` (стандартный для Vite)
|
||||||
|
- Настроен proxy для API запросов к бэкенду (`http://localhost:3001`)
|
||||||
|
- Добавлены proxy правила для `/api`, `/uploads`, `/logout`
|
||||||
|
- ✅ Обновлён `package.json`:
|
||||||
|
- Добавлен скрипт `server` для запуска бэкенда
|
||||||
|
- Добавлен скрипт `dev:all` для одновременного запуска фронтенда и бэкенда
|
||||||
|
- Добавлен скрипт `start` как алиас для `dev:all`
|
||||||
|
- Установлен пакет `concurrently` для параллельного запуска
|
||||||
|
|
||||||
|
**Запуск только фронтенда:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Адрес:** http://localhost:5173
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Запуск всего приложения
|
||||||
|
|
||||||
|
### Вариант 1: Одна команда (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Это запустит фронтенд и бэкенд одновременно.
|
||||||
|
|
||||||
|
### Вариант 2: Раздельный запуск
|
||||||
|
|
||||||
|
**Терминал 1 (Backend):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Терминал 2 (Frontend):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Установка зависимостей
|
||||||
|
|
||||||
|
### Первый раз:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Фронтенд
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Бэкенд
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
Создайте файл `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=your-secret-key-here
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📍 Порты
|
||||||
|
|
||||||
|
- **Frontend (dev):** http://localhost:5173
|
||||||
|
- **Backend (новый):** http://localhost:3001
|
||||||
|
- **Старое приложение:** http://localhost:3000 (если запущено)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Преимущества новой структуры
|
||||||
|
|
||||||
|
1. **Независимость:** Фронтенд и бэкенд могут разрабатываться и деплоиться независимо
|
||||||
|
2. **Современный стек:** Vite для быстрой разработки фронтенда
|
||||||
|
3. **Hot Reload:** Автоматическая перезагрузка при изменениях
|
||||||
|
4. **TypeScript:** Типизация на фронтенде
|
||||||
|
5. **Proxy:** Нет проблем с CORS в разработке
|
||||||
|
6. **Раздельные зависимости:** Чистое разделение фронтенд/бэкенд зависимостей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Документация
|
||||||
|
|
||||||
|
- [QUICK_START.md](QUICK_START.md) - Быстрый старт
|
||||||
|
- [README_RU.md](README_RU.md) - Полная документация на русском
|
||||||
|
- [backend/README.md](backend/README.md) - Документация бэкенда
|
||||||
|
- [DEBUG.md](DEBUG.md) - Отладка и решение проблем
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Совместимость со старым приложением
|
||||||
|
|
||||||
|
Старое приложение (порт 3000) и новое (порты 5173 + 3001) могут работать одновременно.
|
||||||
|
|
||||||
|
**База данных:** Оба приложения используют разные базы данных:
|
||||||
|
|
||||||
|
- Старое: `/database/notes.db`
|
||||||
|
- Новое: `/backend/database/notes.db`
|
||||||
|
|
||||||
|
Если нужно использовать общую базу, можно скопировать файлы из `/database/` в `/backend/database/`.
|
||||||
192
COMMANDS.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# Команды NoteJS React
|
||||||
|
|
||||||
|
## 🔧 Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Автоматическая установка всех зависимостей
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# Или вручную:
|
||||||
|
npm install # Фронтенд
|
||||||
|
cd backend && npm install # Бэкенд
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск всего приложения (рекомендуется)
|
||||||
|
./start.sh
|
||||||
|
# или
|
||||||
|
npm start
|
||||||
|
# или
|
||||||
|
npm run dev:all
|
||||||
|
|
||||||
|
# Только фронтенд
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Только бэкенд
|
||||||
|
npm run server
|
||||||
|
# или
|
||||||
|
cd backend && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production сборка
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Предпросмотр production сборки
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧹 Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка кода (линтинг)
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 Структура команд
|
||||||
|
|
||||||
|
### Package.json (корень)
|
||||||
|
|
||||||
|
- `npm run dev` - Запуск Vite dev сервера (фронтенд)
|
||||||
|
- `npm run build` - Сборка production версии
|
||||||
|
- `npm run preview` - Предпросмотр production сборки
|
||||||
|
- `npm run lint` - Проверка кода
|
||||||
|
- `npm run server` - Запуск бэкенда
|
||||||
|
- `npm run dev:all` - Запуск фронтенда и бэкенда одновременно
|
||||||
|
- `npm start` - Алиас для `dev:all`
|
||||||
|
|
||||||
|
### Backend/package.json
|
||||||
|
|
||||||
|
- `npm start` - Запуск сервера
|
||||||
|
- `npm run dev` - Запуск с nodemon (auto-reload)
|
||||||
|
|
||||||
|
## 🌐 Адреса
|
||||||
|
|
||||||
|
После запуска приложение доступно по адресам:
|
||||||
|
|
||||||
|
- **Frontend (dev):** http://localhost:5173
|
||||||
|
- **Backend API:** http://localhost:3001
|
||||||
|
- **Старая версия:** http://localhost:3000 (если запущена)
|
||||||
|
|
||||||
|
## 🔑 Переменные окружения
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
Создайте файл `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=ваш-секретный-ключ
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Примеры использования
|
||||||
|
|
||||||
|
### Первый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Клонирование репозитория
|
||||||
|
git clone <repo-url>
|
||||||
|
cd notejs-react
|
||||||
|
|
||||||
|
# 2. Установка зависимостей
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# 3. Настройка бэкенда (создать .env)
|
||||||
|
nano backend/.env
|
||||||
|
|
||||||
|
# 4. Запуск
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Терминал 1: Backend с auto-reload
|
||||||
|
cd backend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Терминал 2: Frontend с hot-reload
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Сборка фронтенда
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. Запуск бэкенда
|
||||||
|
cd backend
|
||||||
|
NODE_ENV=production npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Отладка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка портов
|
||||||
|
lsof -i :3001 # Backend
|
||||||
|
lsof -i :5173 # Frontend
|
||||||
|
|
||||||
|
# Логи бэкенда
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Логи фронтенда
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Работа с базой данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Просмотр базы данных (SQLite)
|
||||||
|
sqlite3 backend/database/notes.db
|
||||||
|
|
||||||
|
# Создание резервной копии
|
||||||
|
cp backend/database/notes.db backend/database/notes.db.backup
|
||||||
|
|
||||||
|
# Восстановление
|
||||||
|
cp backend/database/notes.db.backup backend/database/notes.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Обновление зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка устаревших пакетов (фронтенд)
|
||||||
|
npm outdated
|
||||||
|
|
||||||
|
# Обновление (фронтенд)
|
||||||
|
npm update
|
||||||
|
|
||||||
|
# Проверка устаревших пакетов (бэкенд)
|
||||||
|
cd backend
|
||||||
|
npm outdated
|
||||||
|
npm update
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В будущем здесь будут команды для тестов
|
||||||
|
# npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус
|
||||||
|
git status
|
||||||
|
|
||||||
|
# Добавить изменения
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Коммит
|
||||||
|
git commit -m "Описание изменений"
|
||||||
|
|
||||||
|
# Отправка
|
||||||
|
git push
|
||||||
|
```
|
||||||
70
DEBUG.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Инструкция по отладке подключения к backend
|
||||||
|
|
||||||
|
## Проблема: "Ошибка соединения с сервером"
|
||||||
|
|
||||||
|
### Шаг 1: Проверьте, запущен ли backend сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В корне проекта NoteJS
|
||||||
|
cd /home/fovway/git/NoteJS
|
||||||
|
npm start
|
||||||
|
# или
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend должен быть запущен на порту 3000. Проверьте:
|
||||||
|
|
||||||
|
- В консоли должно быть: "Сервер запущен на порту 3000"
|
||||||
|
- В браузере: http://localhost:3000 должен открываться
|
||||||
|
|
||||||
|
### Шаг 2: Проверьте консоль браузера
|
||||||
|
|
||||||
|
Откройте DevTools (F12) и посмотрите в консоль:
|
||||||
|
|
||||||
|
- Если видите "API Request: POST /api/login" - запрос отправляется
|
||||||
|
- Если видите "Network error - server might be down or proxy not working" - backend не отвечает
|
||||||
|
- Если видите ошибку CORS - проблема с настройками сервера
|
||||||
|
|
||||||
|
### Шаг 3: Проверьте прокси Vite
|
||||||
|
|
||||||
|
Vite dev server (порт 3001) должен проксировать запросы на `/api` на `http://localhost:3000`.
|
||||||
|
|
||||||
|
Убедитесь что в `vite.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4: Проверьте сетевые запросы
|
||||||
|
|
||||||
|
В DevTools > Network:
|
||||||
|
|
||||||
|
- Запрос должен быть на `http://localhost:3001/api/login` (не 3000!)
|
||||||
|
- Статус должен быть 200 или 400/401 (не ошибка сети)
|
||||||
|
|
||||||
|
### Шаг 5: Проверьте сессии
|
||||||
|
|
||||||
|
Backend использует cookies для сессий. Убедитесь что:
|
||||||
|
|
||||||
|
- `withCredentials: true` в axios клиенте
|
||||||
|
- Backend настроен для работы с cookies
|
||||||
|
|
||||||
|
### Типичные ошибки:
|
||||||
|
|
||||||
|
1. **"Сервер не отвечает"** - backend не запущен
|
||||||
|
2. **CORS ошибка** - проверьте настройки CORS в server.js
|
||||||
|
3. **404 Not Found** - неправильный URL или прокси не работает
|
||||||
|
4. **Network Error** - backend не доступен или прокси не настроен
|
||||||
|
|
||||||
|
### Быстрое решение:
|
||||||
|
|
||||||
|
1. Убедитесь что backend запущен: `cd /home/fovway/git/NoteJS && npm start`
|
||||||
|
2. Перезапустите Vite dev server: `cd notejs-react && npm run dev`
|
||||||
|
3. Откройте браузер и проверьте консоль для деталей ошибки
|
||||||
141
QUICK_START.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Быстрый старт NoteJS React
|
||||||
|
|
||||||
|
## 🚀 Первый запуск
|
||||||
|
|
||||||
|
### 1. Установка всех зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка зависимостей фронтенда
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Установка зависимостей бэкенда
|
||||||
|
cd backend && npm install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запуск приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск фронтенда и бэкенда одновременно
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу: **http://localhost:5173**
|
||||||
|
|
||||||
|
⚠️ **ВАЖНО:** Открывайте именно **http://localhost:5173** (Frontend), а НЕ http://localhost:3001 (Backend API)
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
Приложение разделено на два независимых сервера:
|
||||||
|
|
||||||
|
1. **Frontend (React + Vite)** - порт 5173
|
||||||
|
|
||||||
|
- Отображает интерфейс пользователя
|
||||||
|
- Обрабатывает роутинг
|
||||||
|
- Проксирует API запросы к бэкенду
|
||||||
|
|
||||||
|
2. **Backend (Node.js + Express)** - порт 3001
|
||||||
|
- Предоставляет REST API
|
||||||
|
- Работает с базой данных
|
||||||
|
- Обрабатывает аутентификацию
|
||||||
|
- Хранит файлы и изображения
|
||||||
|
|
||||||
|
**Как это работает:**
|
||||||
|
|
||||||
|
- Вы открываете http://localhost:5173
|
||||||
|
- Frontend отображает интерфейс
|
||||||
|
- При запросе данных Frontend обращается к Backend API
|
||||||
|
- Backend обрабатывает запрос и возвращает данные
|
||||||
|
|
||||||
|
## 📋 Полезные команды
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск всего приложения (фронтенд + бэкенд)
|
||||||
|
npm start
|
||||||
|
# или
|
||||||
|
npm run dev:all
|
||||||
|
|
||||||
|
# Запуск только фронтенда
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Запуск только бэкенда
|
||||||
|
npm run server
|
||||||
|
# или
|
||||||
|
cd backend && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production сборка фронтенда
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Предпросмотр production сборки
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка кода (линтинг)
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Первоначальная настройка
|
||||||
|
|
||||||
|
### 1. Настройка бэкенда
|
||||||
|
|
||||||
|
Создайте файл `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=замените-на-свой-секретный-ключ
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Первый пользователь
|
||||||
|
|
||||||
|
1. Откройте http://localhost:5173
|
||||||
|
2. Перейдите на страницу регистрации
|
||||||
|
3. Создайте учетную запись
|
||||||
|
|
||||||
|
### 3. Настройка AI (опционально)
|
||||||
|
|
||||||
|
1. Войдите в приложение
|
||||||
|
2. Перейдите в "Настройки"
|
||||||
|
3. Вкладка "AI настройки"
|
||||||
|
4. Введите:
|
||||||
|
- API ключ
|
||||||
|
- Base URL (например: `https://api.openai.com/v1/`)
|
||||||
|
- Модель (например: `gpt-3.5-turbo`)
|
||||||
|
5. Включите "Включить AI функционал"
|
||||||
|
|
||||||
|
## 📍 Адреса
|
||||||
|
|
||||||
|
- **Фронтенд (dev)**: http://localhost:5173
|
||||||
|
- **Бэкенд API**: http://localhost:3001
|
||||||
|
- **Старая версия**: http://localhost:3000 (если запущена)
|
||||||
|
|
||||||
|
## ❓ Проблемы
|
||||||
|
|
||||||
|
### Порт уже занят
|
||||||
|
|
||||||
|
Если порт 3001 или 5173 занят:
|
||||||
|
|
||||||
|
1. Измените порт в `backend/.env` (для бэкенда)
|
||||||
|
2. Измените порт в `vite.config.ts` (для фронтенда)
|
||||||
|
|
||||||
|
### База данных не создается
|
||||||
|
|
||||||
|
Проверьте, что папка `backend/database/` существует и доступна для записи.
|
||||||
|
|
||||||
|
### Не загружаются файлы
|
||||||
|
|
||||||
|
Проверьте, что папка `backend/public/uploads/` существует и доступна для записи.
|
||||||
|
|
||||||
|
## 📖 Документация
|
||||||
|
|
||||||
|
Полная документация в файле [README_RU.md](README_RU.md)
|
||||||
|
|
||||||
|
Backend документация: [backend/README.md](backend/README.md)
|
||||||
119
README.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# NoteJS React
|
||||||
|
|
||||||
|
Современное PWA приложение для ведения заметок на React + TypeScript + Vite
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Установка зависимостей
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# 2. Запуск приложения
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу: **http://localhost:5173**
|
||||||
|
|
||||||
|
## 📋 Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск фронтенда и бэкенда
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Только фронтенд (порт 5173)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Только бэкенд (порт 3001)
|
||||||
|
npm run server
|
||||||
|
|
||||||
|
# Production сборка
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
notejs-react/
|
||||||
|
├── backend/ # Backend сервер (Node.js + Express)
|
||||||
|
│ ├── database/ # SQLite базы данных
|
||||||
|
│ ├── public/ # Статические файлы и загрузки
|
||||||
|
│ └── server.js # Основной файл сервера
|
||||||
|
├── src/ # Frontend (React + TypeScript)
|
||||||
|
│ ├── api/ # API клиенты
|
||||||
|
│ ├── components/ # React компоненты
|
||||||
|
│ ├── pages/ # Страницы приложения
|
||||||
|
│ ├── store/ # Redux store
|
||||||
|
│ ├── hooks/ # Кастомные хуки
|
||||||
|
│ ├── utils/ # Утилиты
|
||||||
|
│ └── styles/ # CSS стили
|
||||||
|
├── public/ # Публичные файлы PWA
|
||||||
|
└── vite.config.ts # Конфигурация Vite
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Адреса
|
||||||
|
|
||||||
|
- **Frontend (dev):** http://localhost:5173
|
||||||
|
- **Backend API:** http://localhost:3001
|
||||||
|
- **Старая версия:** http://localhost:3000 (не конфликтует)
|
||||||
|
|
||||||
|
## 📖 Документация
|
||||||
|
|
||||||
|
- **[QUICK_START.md](QUICK_START.md)** - Инструкции для начинающих
|
||||||
|
- **[README_RU.md](README_RU.md)** - Полная документация на русском
|
||||||
|
- **[COMMANDS.md](COMMANDS.md)** - Справка по командам
|
||||||
|
- **[CHANGES.md](CHANGES.md)** - Список изменений
|
||||||
|
- **[backend/README.md](backend/README.md)** - Документация бэкенда
|
||||||
|
|
||||||
|
## ✨ Функционал
|
||||||
|
|
||||||
|
- 📝 Markdown редактор с поддержкой форматирования
|
||||||
|
- 🖼️ Загрузка изображений
|
||||||
|
- 📎 Прикрепление файлов
|
||||||
|
- 📌 Закрепление заметок
|
||||||
|
- 📦 Архивация
|
||||||
|
- 🔍 Поиск и фильтры
|
||||||
|
- 🏷️ Система тегов
|
||||||
|
- 🌓 Тёмная/светлая тема
|
||||||
|
- 🤖 AI функционал
|
||||||
|
- 📱 PWA с офлайн поддержкой
|
||||||
|
- 📅 Календарь заметок
|
||||||
|
|
||||||
|
## 🛠️ Технологии
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- React 18 + TypeScript
|
||||||
|
- Redux Toolkit
|
||||||
|
- Vite
|
||||||
|
- Marked (Markdown)
|
||||||
|
- Axios
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Node.js + Express
|
||||||
|
- SQLite3
|
||||||
|
- Multer (загрузка файлов)
|
||||||
|
- Bcrypt (аутентификация)
|
||||||
|
|
||||||
|
## 📦 Требования
|
||||||
|
|
||||||
|
- Node.js >= 14
|
||||||
|
- npm >= 6
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
Создайте файл `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=your-secret-key-here
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Вклад в проект
|
||||||
|
|
||||||
|
Приветствуются pull requests и issue!
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
ISC
|
||||||
198
README_RU.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# NoteJS React - Приложение для заметок
|
||||||
|
|
||||||
|
Современное PWA приложение для ведения заметок с поддержкой Markdown, изображений, файлов и AI.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
notejs-react/
|
||||||
|
├── backend/ # Backend сервер (Node.js + Express)
|
||||||
|
│ ├── database/ # SQLite базы данных
|
||||||
|
│ ├── public/ # Статические файлы и загрузки
|
||||||
|
│ ├── server.js # Основной файл сервера
|
||||||
|
│ └── package.json # Зависимости бэкенда
|
||||||
|
├── src/ # Frontend исходники (React + TypeScript)
|
||||||
|
│ ├── api/ # API клиенты
|
||||||
|
│ ├── components/ # React компоненты
|
||||||
|
│ ├── hooks/ # Кастомные хуки
|
||||||
|
│ ├── pages/ # Страницы приложения
|
||||||
|
│ ├── store/ # Redux store
|
||||||
|
│ ├── styles/ # CSS стили
|
||||||
|
│ ├── types/ # TypeScript типы
|
||||||
|
│ └── utils/ # Утилиты
|
||||||
|
├── public/ # Публичные файлы (иконки, манифест)
|
||||||
|
└── package.json # Зависимости фронтенда
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Установка зависимостей фронтенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Установка зависимостей бэкенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Вариант 1: Запуск всего приложения (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Это запустит:
|
||||||
|
|
||||||
|
- **Frontend** на `http://localhost:5173`
|
||||||
|
- **Backend** на `http://localhost:3001`
|
||||||
|
|
||||||
|
### Вариант 2: Раздельный запуск
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend (в отдельном терминале):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Функционал
|
||||||
|
|
||||||
|
### ✨ Основные возможности
|
||||||
|
|
||||||
|
- 📝 Создание и редактирование заметок с Markdown
|
||||||
|
- 🖼️ Загрузка изображений к заметкам
|
||||||
|
- 📎 Прикрепление файлов (PDF, DOC, XLS, ZIP и др.)
|
||||||
|
- 📌 Закрепление важных заметок
|
||||||
|
- 📦 Архивация заметок
|
||||||
|
- 🔍 Поиск по заметкам и тегам
|
||||||
|
- 📅 Календарь заметок
|
||||||
|
- 🏷️ Система тегов
|
||||||
|
- 🌓 Светлая/тёмная тема
|
||||||
|
- 🎨 Настройка цветовой схемы
|
||||||
|
- 👤 Профили пользователей с аватарами
|
||||||
|
|
||||||
|
### 🤖 AI функционал
|
||||||
|
|
||||||
|
- Улучшение текста заметок
|
||||||
|
- Исправление ошибок
|
||||||
|
- Генерация контента
|
||||||
|
- Настройка собственного OpenAI-совместимого API
|
||||||
|
|
||||||
|
### 📱 PWA
|
||||||
|
|
||||||
|
- Установка как приложение
|
||||||
|
- Работа офлайн (Service Worker)
|
||||||
|
- Push-уведомления (опционально)
|
||||||
|
- Адаптивный дизайн для всех устройств
|
||||||
|
|
||||||
|
### Markdown поддержка
|
||||||
|
|
||||||
|
- Заголовки (H1-H5)
|
||||||
|
- **Жирный** и _курсив_
|
||||||
|
- Списки (маркированные и нумерованные)
|
||||||
|
- ~~Зачеркнутый текст~~
|
||||||
|
- [Ссылки](https://example.com)
|
||||||
|
- `Код` и блоки кода
|
||||||
|
- Todo списки с чекбоксами
|
||||||
|
- ||Спойлеры||
|
||||||
|
- Внешние ссылки
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Redux Toolkit
|
||||||
|
- React Router
|
||||||
|
- Vite
|
||||||
|
- Axios
|
||||||
|
- Marked (Markdown)
|
||||||
|
- Iconify
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- Express
|
||||||
|
- SQLite3
|
||||||
|
- Bcrypt
|
||||||
|
- Multer
|
||||||
|
- Express Session
|
||||||
|
- Helmet
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Сборка production версии
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Собранные файлы будут в папке `dist/`.
|
||||||
|
|
||||||
|
### Проверка типов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
Создайте файл `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=your-secret-key-here
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI настройки
|
||||||
|
|
||||||
|
Настройки AI выполняются через интерфейс приложения в разделе "Настройки".
|
||||||
|
|
||||||
|
Требуется:
|
||||||
|
|
||||||
|
- OpenAI API Key (или ключ совместимого API)
|
||||||
|
- Base URL (например, `https://api.openai.com/v1/`)
|
||||||
|
- Модель (например, `gpt-3.5-turbo`)
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
- **Frontend (dev)**: `http://localhost:5173`
|
||||||
|
- **Backend**: `http://localhost:3001`
|
||||||
|
- **Старое приложение**: `http://localhost:3000` (если запущено)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|
||||||
|
## Автор
|
||||||
|
|
||||||
|
NoteJS Team
|
||||||
35
backend/.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
database/*.db
|
||||||
|
database/*.db-shm
|
||||||
|
database/*.db-wal
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
68
backend/README.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# NoteJS Backend
|
||||||
|
|
||||||
|
Backend сервер для приложения NoteJS на Node.js и Express.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
Создайте файл `.env` в корне папки `backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
SESSION_SECRET=your-secret-key-here-change-in-production
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Отдельный запуск бэкенда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск фронтенда и бэкенда вместе:
|
||||||
|
|
||||||
|
Из корня проекта `notejs-react`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
- **Backend**: `http://localhost:3001`
|
||||||
|
- **Frontend (dev)**: `http://localhost:5173`
|
||||||
|
|
||||||
|
Frontend автоматически проксирует API запросы к бэкенду.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `database/` - SQLite базы данных (notes.db, sessions.db)
|
||||||
|
- `public/uploads/` - загруженные файлы (аватары, изображения, файлы заметок)
|
||||||
|
- `server.js` - основной файл сервера
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `/api/auth/*` - аутентификация
|
||||||
|
- `/api/notes/*` - работа с заметками
|
||||||
|
- `/api/user/*` - профиль пользователя
|
||||||
|
- `/api/ai/*` - AI функционал
|
||||||
|
- `/api/logs` - логи действий
|
||||||
3894
backend/package-lock.json
generated
Normal file
44
backend/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "notejs",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.fovway.ru/Fovway/NoteJS.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/basic-setup": "^0.20.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.4.0",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.38.6",
|
||||||
|
"@iconify/iconify": "^3.1.1",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"connect-sqlite3": "^0.9.16",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.1.0",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"marked": "^16.4.0",
|
||||||
|
"multer": "^2.0.0-rc.4",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"sharp": "^0.34.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/public/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/icons/icon-152x152.png"/>
|
||||||
|
<TileColor>#007bff</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
41
backend/public/icon.svg
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Фон -->
|
||||||
|
<rect width="512" height="512" rx="80" fill="#007bff"/>
|
||||||
|
|
||||||
|
<!-- Основная иконка заметки -->
|
||||||
|
<rect x="80" y="100" width="280" height="360" rx="20" fill="white" stroke="#e3f2fd" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- Заголовок заметки -->
|
||||||
|
<rect x="100" y="120" width="240" height="20" rx="10" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Строки текста -->
|
||||||
|
<rect x="100" y="160" width="200" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
<rect x="100" y="180" width="180" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
<rect x="100" y="200" width="220" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Список -->
|
||||||
|
<circle cx="110" cy="240" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="236" width="120" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<circle cx="110" cy="260" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="256" width="100" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<circle cx="110" cy="280" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="276" width="140" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Код блок -->
|
||||||
|
<rect x="100" y="320" width="240" height="60" rx="8" fill="#f5f5f5" stroke="#e0e0e0" stroke-width="2"/>
|
||||||
|
<rect x="110" y="330" width="40" height="6" rx="3" fill="#007bff"/>
|
||||||
|
<rect x="110" y="340" width="60" height="6" rx="3" fill="#666"/>
|
||||||
|
<rect x="110" y="350" width="50" height="6" rx="3" fill="#666"/>
|
||||||
|
<rect x="110" y="360" width="30" height="6" rx="3" fill="#007bff"/>
|
||||||
|
|
||||||
|
<!-- Тег -->
|
||||||
|
<rect x="100" y="400" width="60" height="20" rx="10" fill="#e7f3ff" stroke="#007bff" stroke-width="2"/>
|
||||||
|
<text x="130" y="413" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#007bff">#tag</text>
|
||||||
|
|
||||||
|
<!-- Дополнительные элементы для живости -->
|
||||||
|
<circle cx="400" cy="150" r="8" fill="white" opacity="0.3"/>
|
||||||
|
<circle cx="450" cy="200" r="6" fill="white" opacity="0.2"/>
|
||||||
|
<circle cx="420" cy="300" r="10" fill="white" opacity="0.25"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
backend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
backend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
backend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
backend/public/icons/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 428 B |
BIN
backend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
backend/public/icons/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 715 B |
BIN
backend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
backend/public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
backend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
backend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
132
backend/public/index.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NoteJS Backend</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(76, 175, 80, 0.3);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 25px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
border: 2px solid rgba(76, 175, 80, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 30px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #667eea;
|
||||||
|
padding: 15px 40px;
|
||||||
|
border-radius: 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: 3em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="emoji">🚀</div>
|
||||||
|
<h1>NoteJS Backend API</h1>
|
||||||
|
|
||||||
|
<div class="status">✅ Сервер работает</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p>Это Backend API сервер NoteJS React.</p>
|
||||||
|
<p>Для доступа к приложению используйте Frontend:</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code">
|
||||||
|
<strong>Frontend (Development):</strong><br />
|
||||||
|
http://localhost:5173
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="http://localhost:5173" class="button"> Открыть приложение </a>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
style="margin-top: 40px; font-size: 0.9em; opacity: 0.8"
|
||||||
|
>
|
||||||
|
<p><strong>Backend API:</strong> http://localhost:3001</p>
|
||||||
|
<p>
|
||||||
|
<strong>Endpoints:</strong> /api/auth, /api/notes, /api/user, /api/ai
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Автоматическое перенаправление на frontend через 3 секунды
|
||||||
|
setTimeout(() => {
|
||||||
|
if (confirm("Перейти на frontend приложение?")) {
|
||||||
|
window.location.href = "http://localhost:5173";
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
backend/public/logo.svg
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<svg width="400" height="120" viewBox="0 0 400 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Фон -->
|
||||||
|
<rect width="400" height="120" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Иконка логотипа -->
|
||||||
|
<g transform="translate(20, 20)">
|
||||||
|
<!-- Основная иконка заметки -->
|
||||||
|
<rect x="0" y="0" width="60" height="80" rx="8" fill="#007bff" stroke="#0056b3" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Заголовок заметки -->
|
||||||
|
<rect x="8" y="8" width="44" height="6" rx="3" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Строки текста -->
|
||||||
|
<rect x="8" y="20" width="36" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="8" y="28" width="32" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="8" y="36" width="40" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Список -->
|
||||||
|
<circle cx="12" cy="50" r="2" fill="white"/>
|
||||||
|
<rect x="18" y="49" width="20" height="2" rx="1" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<circle cx="12" cy="58" r="2" fill="white"/>
|
||||||
|
<rect x="18" y="57" width="16" height="2" rx="1" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Тег -->
|
||||||
|
<rect x="8" y="68" width="20" height="8" rx="4" fill="white" opacity="0.9"/>
|
||||||
|
<text x="18" y="73" text-anchor="middle" font-family="Arial, sans-serif" font-size="4" font-weight="bold" fill="#007bff">#</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Текст логотипа -->
|
||||||
|
<g transform="translate(100, 0)">
|
||||||
|
<!-- Название приложения -->
|
||||||
|
<text x="0" y="45" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#007bff">
|
||||||
|
NoteJS
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Подзаголовок -->
|
||||||
|
<text x="0" y="65" font-family="Arial, sans-serif" font-size="14" fill="#666">
|
||||||
|
Система заметок
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Дополнительные элементы -->
|
||||||
|
<circle cx="280" cy="25" r="3" fill="#007bff" opacity="0.3"/>
|
||||||
|
<circle cx="320" cy="35" r="2" fill="#007bff" opacity="0.2"/>
|
||||||
|
<circle cx="300" cy="55" r="4" fill="#007bff" opacity="0.25"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
86
backend/public/manifest.json
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "NoteJS - Система заметок",
|
||||||
|
"short_name": "NoteJS",
|
||||||
|
"description": "Современная система заметок с поддержкой Markdown, изображений, тегов и календаря",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#007bff",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "ru",
|
||||||
|
"id": "/",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
backend/public/sw.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// NoteJS Backend Service Worker
|
||||||
|
// Минимальный SW для предотвращения ошибок
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
console.log("Backend SW installed");
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
console.log("Backend SW activated");
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
// Просто пропускаем все запросы через сеть
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
||||||
0
backend/public/uploads/.gitkeep
Normal file
2665
backend/server.js
Normal file
100
index.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<title>NoteJS - Система заметок</title>
|
||||||
|
|
||||||
|
<!-- Предотвращение мерцания темы -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem("theme");
|
||||||
|
const systemPrefersDark = window.matchMedia(
|
||||||
|
"(prefers-color-scheme: dark)"
|
||||||
|
).matches;
|
||||||
|
const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
|
||||||
|
|
||||||
|
if (theme === "dark") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="NoteJS - современная система заметок с поддержкой Markdown, изображений, тегов и календаря"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#007bff" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
|
content="black-translucent"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NoteJS" />
|
||||||
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
|
<meta name="msapplication-TileColor" content="#007bff" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
|
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png" />
|
||||||
|
<meta name="application-name" content="NoteJS" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/icons/icon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/icons/icon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="/icons/icon-48x48.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="/icons/icon-48x48.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="/icons/icon-72x72.png" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="114x114"
|
||||||
|
href="/icons/icon-128x128.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="120x120"
|
||||||
|
href="/icons/icon-128x128.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="144x144"
|
||||||
|
href="/icons/icon-144x144.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="152x152"
|
||||||
|
href="/icons/icon-152x152.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/icons/icon-192x192.png"
|
||||||
|
/>
|
||||||
|
<link rel="mask-icon" href="/icon.svg" color="#007bff" />
|
||||||
|
|
||||||
|
<!-- Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
install.sh
Executable file
@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для установки всех зависимостей NoteJS React
|
||||||
|
|
||||||
|
echo "📦 Установка зависимостей NoteJS React..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Установка зависимостей фронтенда
|
||||||
|
echo "1️⃣ Установка зависимостей фронтенда..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Ошибка установки зависимостей фронтенда"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2️⃣ Установка зависимостей бэкенда..."
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Ошибка установки зависимостей бэкенда"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Все зависимости успешно установлены!"
|
||||||
|
echo ""
|
||||||
|
echo "Для запуска приложения выполните:"
|
||||||
|
echo " npm start"
|
||||||
|
echo "или"
|
||||||
|
echo " ./start.sh"
|
||||||
|
|
||||||
7561
package-lock.json
generated
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "notejs-react",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"server": "cd backend && node server.js",
|
||||||
|
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
||||||
|
"start": "npm run dev:all"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@iconify/react": "^4.1.1",
|
||||||
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"marked": "^16.4.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-pwa": "^0.17.5",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
public/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/icons/icon-152x152.png"/>
|
||||||
|
<TileColor>#007bff</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
41
public/icon.svg
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Фон -->
|
||||||
|
<rect width="512" height="512" rx="80" fill="#007bff"/>
|
||||||
|
|
||||||
|
<!-- Основная иконка заметки -->
|
||||||
|
<rect x="80" y="100" width="280" height="360" rx="20" fill="white" stroke="#e3f2fd" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- Заголовок заметки -->
|
||||||
|
<rect x="100" y="120" width="240" height="20" rx="10" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Строки текста -->
|
||||||
|
<rect x="100" y="160" width="200" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
<rect x="100" y="180" width="180" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
<rect x="100" y="200" width="220" height="12" rx="6" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Список -->
|
||||||
|
<circle cx="110" cy="240" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="236" width="120" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<circle cx="110" cy="260" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="256" width="100" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<circle cx="110" cy="280" r="4" fill="#007bff"/>
|
||||||
|
<rect x="125" y="276" width="140" height="8" rx="4" fill="#e3f2fd"/>
|
||||||
|
|
||||||
|
<!-- Код блок -->
|
||||||
|
<rect x="100" y="320" width="240" height="60" rx="8" fill="#f5f5f5" stroke="#e0e0e0" stroke-width="2"/>
|
||||||
|
<rect x="110" y="330" width="40" height="6" rx="3" fill="#007bff"/>
|
||||||
|
<rect x="110" y="340" width="60" height="6" rx="3" fill="#666"/>
|
||||||
|
<rect x="110" y="350" width="50" height="6" rx="3" fill="#666"/>
|
||||||
|
<rect x="110" y="360" width="30" height="6" rx="3" fill="#007bff"/>
|
||||||
|
|
||||||
|
<!-- Тег -->
|
||||||
|
<rect x="100" y="400" width="60" height="20" rx="10" fill="#e7f3ff" stroke="#007bff" stroke-width="2"/>
|
||||||
|
<text x="130" y="413" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#007bff">#tag</text>
|
||||||
|
|
||||||
|
<!-- Дополнительные элементы для живости -->
|
||||||
|
<circle cx="400" cy="150" r="8" fill="white" opacity="0.3"/>
|
||||||
|
<circle cx="450" cy="200" r="6" fill="white" opacity="0.2"/>
|
||||||
|
<circle cx="420" cy="300" r="10" fill="white" opacity="0.25"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/icons/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 428 B |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/icons/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 715 B |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
47
public/logo.svg
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<svg width="400" height="120" viewBox="0 0 400 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Фон -->
|
||||||
|
<rect width="400" height="120" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Иконка логотипа -->
|
||||||
|
<g transform="translate(20, 20)">
|
||||||
|
<!-- Основная иконка заметки -->
|
||||||
|
<rect x="0" y="0" width="60" height="80" rx="8" fill="#007bff" stroke="#0056b3" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Заголовок заметки -->
|
||||||
|
<rect x="8" y="8" width="44" height="6" rx="3" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Строки текста -->
|
||||||
|
<rect x="8" y="20" width="36" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="8" y="28" width="32" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="8" y="36" width="40" height="4" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Список -->
|
||||||
|
<circle cx="12" cy="50" r="2" fill="white"/>
|
||||||
|
<rect x="18" y="49" width="20" height="2" rx="1" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<circle cx="12" cy="58" r="2" fill="white"/>
|
||||||
|
<rect x="18" y="57" width="16" height="2" rx="1" fill="white" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Тег -->
|
||||||
|
<rect x="8" y="68" width="20" height="8" rx="4" fill="white" opacity="0.9"/>
|
||||||
|
<text x="18" y="73" text-anchor="middle" font-family="Arial, sans-serif" font-size="4" font-weight="bold" fill="#007bff">#</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Текст логотипа -->
|
||||||
|
<g transform="translate(100, 0)">
|
||||||
|
<!-- Название приложения -->
|
||||||
|
<text x="0" y="45" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#007bff">
|
||||||
|
NoteJS
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Подзаголовок -->
|
||||||
|
<text x="0" y="65" font-family="Arial, sans-serif" font-size="14" fill="#666">
|
||||||
|
Система заметок
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Дополнительные элементы -->
|
||||||
|
<circle cx="280" cy="25" r="3" fill="#007bff" opacity="0.3"/>
|
||||||
|
<circle cx="320" cy="35" r="2" fill="#007bff" opacity="0.2"/>
|
||||||
|
<circle cx="300" cy="55" r="4" fill="#007bff" opacity="0.25"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
86
public/manifest.json
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "NoteJS - Система заметок",
|
||||||
|
"short_name": "NoteJS",
|
||||||
|
"description": "Современная система заметок с поддержкой Markdown, изображений, тегов и календаря",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#007bff",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "ru",
|
||||||
|
"id": "/",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
63
src/App.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { store } from "./store";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import RegisterPage from "./pages/RegisterPage";
|
||||||
|
import NotesPage from "./pages/NotesPage";
|
||||||
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import { NotificationStack } from "./components/common/Notification";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
import { useTheme } from "./hooks/useTheme";
|
||||||
|
|
||||||
|
const AppContent: React.FC = () => {
|
||||||
|
useTheme(); // Инициализируем тему
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NotificationStack />
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/notes"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<NotesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<AppContent />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
11
src/api/aiApi.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import axiosClient from "./axiosClient";
|
||||||
|
|
||||||
|
export const aiApi = {
|
||||||
|
improveText: async (text: string): Promise<string> => {
|
||||||
|
const { data } = await axiosClient.post<{ improvedText: string }>(
|
||||||
|
"/ai/improve",
|
||||||
|
{ text }
|
||||||
|
);
|
||||||
|
return data.improvedText;
|
||||||
|
},
|
||||||
|
};
|
||||||
31
src/api/authApi.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import axiosClient from "./axiosClient";
|
||||||
|
import { AuthResponse } from "../types/user";
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const { data } = await axiosClient.post("/login", { username, password });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
confirmPassword: string
|
||||||
|
) => {
|
||||||
|
const { data } = await axiosClient.post("/register", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
checkStatus: async (): Promise<AuthResponse> => {
|
||||||
|
const { data } = await axiosClient.get<AuthResponse>("/auth/status");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await axiosClient.post("/logout");
|
||||||
|
},
|
||||||
|
};
|
||||||
65
src/api/axiosClient.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const axiosClient = axios.create({
|
||||||
|
baseURL: "/api",
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем interceptor для логирования запросов (для отладки)
|
||||||
|
axiosClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
console.log("API Request:", config.method?.toUpperCase(), config.url);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
console.log("API Response:", response.status, response.config.url);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("API Error:", {
|
||||||
|
url: error.config?.url,
|
||||||
|
status: error.response?.status,
|
||||||
|
message: error.message,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Список URL, где 401 означает неправильный пароль, а не истечение сессии
|
||||||
|
const passwordProtectedUrls = [
|
||||||
|
"/notes/archived/all", // Удаление всех архивных заметок
|
||||||
|
"/user/delete-account", // Удаление аккаунта
|
||||||
|
];
|
||||||
|
|
||||||
|
// Проверяем, является ли это запросом с проверкой пароля
|
||||||
|
const isPasswordProtected = passwordProtectedUrls.some((url) =>
|
||||||
|
error.config?.url?.includes(url)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Разлогиниваем только если это НЕ запрос с проверкой пароля
|
||||||
|
if (!isPasswordProtected) {
|
||||||
|
localStorage.removeItem("isAuthenticated");
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если это ошибка сети (нет ответа от сервера)
|
||||||
|
if (!error.response) {
|
||||||
|
console.error(
|
||||||
|
"Network error - server might be down or proxy not working"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default axiosClient;
|
||||||
120
src/api/notesApi.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import axiosClient from "./axiosClient";
|
||||||
|
import { Note } from "../types/note";
|
||||||
|
|
||||||
|
export const notesApi = {
|
||||||
|
getAll: async (): Promise<Note[]> => {
|
||||||
|
const { data } = await axiosClient.get<Note[]>("/notes");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
search: async (params: {
|
||||||
|
q?: string;
|
||||||
|
tag?: string;
|
||||||
|
date?: string;
|
||||||
|
}): Promise<Note[]> => {
|
||||||
|
const { data } = await axiosClient.get<Note[]>("/notes/search", { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (note: { content: string; date: string; time: string }) => {
|
||||||
|
const { data } = await axiosClient.post("/notes", note);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, content: string, skipTimestamp?: boolean) => {
|
||||||
|
const { data } = await axiosClient.put(`/notes/${id}`, {
|
||||||
|
content,
|
||||||
|
skipTimestamp,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number) => {
|
||||||
|
await axiosClient.delete(`/notes/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
pin: async (id: number) => {
|
||||||
|
const { data } = await axiosClient.put(`/notes/${id}/pin`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
archive: async (id: number) => {
|
||||||
|
const { data } = await axiosClient.put(`/notes/${id}/archive`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
unarchive: async (id: number) => {
|
||||||
|
const { data } = await axiosClient.put(`/notes/${id}/unarchive`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadImages: async (noteId: number, files: File[]) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file) => formData.append("images", file));
|
||||||
|
|
||||||
|
const { data } = await axiosClient.post(
|
||||||
|
`/notes/${noteId}/images`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadFiles: async (noteId: number, files: File[]) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file) => formData.append("files", file));
|
||||||
|
|
||||||
|
const { data } = await axiosClient.post(
|
||||||
|
`/notes/${noteId}/files`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteImage: async (noteId: number, imageId: number) => {
|
||||||
|
await axiosClient.delete(`/notes/${noteId}/images/${imageId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFile: async (noteId: number, fileId: number) => {
|
||||||
|
await axiosClient.delete(`/notes/${noteId}/files/${fileId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getArchived: async (): Promise<Note[]> => {
|
||||||
|
const { data } = await axiosClient.get<Note[]>("/notes/archived");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteArchived: async (id: number) => {
|
||||||
|
await axiosClient.delete(`/notes/archived/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAllArchived: async (password: string) => {
|
||||||
|
const { data } = await axiosClient.delete("/notes/archived/all", {
|
||||||
|
data: { password },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
id: number;
|
||||||
|
action_type: string;
|
||||||
|
details: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logsApi = {
|
||||||
|
getLogs: async (params: {
|
||||||
|
action_type?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<Log[]> => {
|
||||||
|
const { data } = await axiosClient.get<Log[]>("/logs", { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
51
src/api/userApi.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import axiosClient from "./axiosClient";
|
||||||
|
import { User, AiSettings } from "../types/user";
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
getProfile: async (): Promise<User> => {
|
||||||
|
const { data } = await axiosClient.get<User>("/user");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: async (
|
||||||
|
profile: Partial<User> & {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
accent_color?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { data } = await axiosClient.put("/user/profile", profile);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadAvatar: async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("avatar", file);
|
||||||
|
|
||||||
|
const { data } = await axiosClient.post("/user/avatar", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAvatar: async () => {
|
||||||
|
await axiosClient.delete("/user/avatar");
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAccount: async (password: string) => {
|
||||||
|
const { data } = await axiosClient.delete("/user/delete-account", {
|
||||||
|
data: { password },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAiSettings: async (): Promise<AiSettings> => {
|
||||||
|
const { data } = await axiosClient.get<AiSettings>("/user/ai-settings");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAiSettings: async (settings: Partial<AiSettings>) => {
|
||||||
|
const { data } = await axiosClient.put("/user/ai-settings", settings);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
47
src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||||
|
import { setAuth, clearAuth } from "../store/slices/authSlice";
|
||||||
|
import { authApi } from "../api/authApi";
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const authStatus = await authApi.checkStatus();
|
||||||
|
if (authStatus.authenticated) {
|
||||||
|
dispatch(
|
||||||
|
setAuth({
|
||||||
|
userId: authStatus.userId!,
|
||||||
|
username: authStatus.username!,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(clearAuth());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dispatch(clearAuth());
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
checkAuth();
|
||||||
|
} else {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [dispatch, isAuthenticated]);
|
||||||
|
|
||||||
|
if (isChecking) {
|
||||||
|
return <div>Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
|
||||||
|
};
|
||||||
117
src/components/calendar/MiniCalendar.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
eachDayOfInterval,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
} from "date-fns";
|
||||||
|
import { ru } from "date-fns/locale";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { setSelectedDate } from "../../store/slices/notesSlice";
|
||||||
|
import { formatDateFromTimestamp } from "../../utils/dateFormat";
|
||||||
|
import { Note } from "../../types/note";
|
||||||
|
|
||||||
|
interface MiniCalendarProps {
|
||||||
|
notes?: Note[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MiniCalendar: React.FC<MiniCalendarProps> = ({ notes = [] }) => {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentDate);
|
||||||
|
const monthEnd = endOfMonth(currentDate);
|
||||||
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||||
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||||
|
|
||||||
|
// Получаем даты с заметками
|
||||||
|
const createdNoteDates = new Set<string>();
|
||||||
|
const editedNoteDates = new Set<string>();
|
||||||
|
|
||||||
|
notes.forEach((note) => {
|
||||||
|
if (note.created_at) {
|
||||||
|
createdNoteDates.add(formatDateFromTimestamp(note.created_at));
|
||||||
|
}
|
||||||
|
if (note.updated_at && note.created_at !== note.updated_at) {
|
||||||
|
editedNoteDates.add(formatDateFromTimestamp(note.updated_at));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDayClick = (day: Date) => {
|
||||||
|
const dateStr = format(day, "dd.MM.yyyy");
|
||||||
|
if (selectedDate === dateStr) {
|
||||||
|
dispatch(setSelectedDate(null));
|
||||||
|
} else {
|
||||||
|
dispatch(setSelectedDate(dateStr));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
setCurrentDate(subMonths(currentDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setCurrentDate(addMonths(currentDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthYear = format(currentDate, "MMMM yyyy", { locale: ru });
|
||||||
|
const capitalizedMonthYear =
|
||||||
|
monthYear.charAt(0).toUpperCase() + monthYear.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mini-calendar">
|
||||||
|
<div className="calendar-header">
|
||||||
|
<button className="calendar-nav" onClick={handlePrevMonth}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="calendar-month-year">{capitalizedMonthYear}</span>
|
||||||
|
<button className="calendar-nav" onClick={handleNextMonth}>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="calendar-weekdays">
|
||||||
|
<div className="calendar-weekday">Пн</div>
|
||||||
|
<div className="calendar-weekday">Вт</div>
|
||||||
|
<div className="calendar-weekday">Ср</div>
|
||||||
|
<div className="calendar-weekday">Чт</div>
|
||||||
|
<div className="calendar-weekday">Пт</div>
|
||||||
|
<div className="calendar-weekday">Сб</div>
|
||||||
|
<div className="calendar-weekday">Вс</div>
|
||||||
|
</div>
|
||||||
|
<div className="calendar-days">
|
||||||
|
{days.map((day, index) => {
|
||||||
|
const dateStr = format(day, "dd.MM.yyyy");
|
||||||
|
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||||
|
const isSelected = selectedDate === dateStr;
|
||||||
|
const hasNotes = createdNoteDates.has(dateStr);
|
||||||
|
const hasEditedNotes = editedNoteDates.has(dateStr);
|
||||||
|
const isToday = isSameDay(day, new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`calendar-day ${
|
||||||
|
!isCurrentMonth ? "other-month" : ""
|
||||||
|
} ${hasNotes ? "has-notes" : ""} ${
|
||||||
|
hasEditedNotes ? "has-edited-notes" : ""
|
||||||
|
} ${isSelected ? "selected" : ""} ${isToday ? "today" : ""}`}
|
||||||
|
data-date={dateStr}
|
||||||
|
onClick={() => handleDayClick(day)}
|
||||||
|
>
|
||||||
|
{format(day, "d")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
src/components/common/Avatar.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src?: string;
|
||||||
|
username: string;
|
||||||
|
size?: "small" | "medium" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
src,
|
||||||
|
username,
|
||||||
|
size = "medium",
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
small: "avatar-small",
|
||||||
|
medium: "avatar-medium",
|
||||||
|
large: "avatar-large",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
return (
|
||||||
|
<div className={`avatar ${sizeClasses[size]}`}>
|
||||||
|
<img src={src} alt={username} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = username
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join("")
|
||||||
|
.substring(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`avatar avatar-placeholder ${sizeClasses[size]}`}>
|
||||||
|
{initials || <Icon icon="mdi:account" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
src/components/common/ImageModal.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const ImageModal: React.FC = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [imageSrc, setImageSrc] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleImageClick = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains("note-image")) {
|
||||||
|
const src =
|
||||||
|
target.getAttribute("src") || target.getAttribute("data-src");
|
||||||
|
if (src) {
|
||||||
|
setImageSrc(src);
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleImageClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleImageClick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="imageModal"
|
||||||
|
className="image-modal"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<span className="image-modal-close" onClick={handleClose}>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
className="image-modal-content"
|
||||||
|
id="modalImage"
|
||||||
|
src={imageSrc}
|
||||||
|
alt="Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
src/components/common/Modal.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React, { useEffect, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string | ReactNode;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmType?: "primary" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = "OK",
|
||||||
|
cancelText = "Отмена",
|
||||||
|
confirmType = "primary",
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal" style={{ display: "block" }} onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<span className="modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{typeof message === "string" ? <p>{message}</p> : message}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className={confirmType === "danger" ? "btn-danger" : "btn-primary"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{ marginRight: "10px" }}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
60
src/components/common/Notification.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { removeNotification } from "../../store/slices/uiSlice";
|
||||||
|
|
||||||
|
export const NotificationStack: React.FC = () => {
|
||||||
|
const notifications = useAppSelector((state) => state.ui.notifications);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notification-stack">
|
||||||
|
{notifications.map((notification, index) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
index={index}
|
||||||
|
onRemove={() => dispatch(removeNotification(notification.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationItem: React.FC<{
|
||||||
|
notification: { id: string; message: string; type: string };
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
}> = ({ notification, index, onRemove }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => setIsVisible(true), 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Автоматическое скрытие уведомления через 4 секунды
|
||||||
|
useEffect(() => {
|
||||||
|
const autoHideTimer = setTimeout(() => {
|
||||||
|
handleRemove();
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => clearTimeout(autoHideTimer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(onRemove, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`notification notification-${notification.type} ${
|
||||||
|
isVisible ? "visible" : ""
|
||||||
|
}`}
|
||||||
|
style={{ top: `${20 + index * 70}px` }}
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
{notification.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/components/common/ThemeToggle.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
|
|
||||||
|
export const ThemeToggle: React.FC = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id="theme-toggle-btn"
|
||||||
|
className="theme-toggle-btn"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title="Переключить тему"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={theme === "dark" ? "mdi:weather-sunny" : "mdi:weather-night"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { clearAuth } from "../../store/slices/authSlice";
|
||||||
|
import {
|
||||||
|
setSelectedDate,
|
||||||
|
setSelectedTag,
|
||||||
|
setSearchQuery,
|
||||||
|
} from "../../store/slices/notesSlice";
|
||||||
|
import { authApi } from "../../api/authApi";
|
||||||
|
import { userApi } from "../../api/userApi";
|
||||||
|
import { ThemeToggle } from "../common/ThemeToggle";
|
||||||
|
import { setUser, setAiSettings } from "../../store/slices/profileSlice";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onFilterChange?: (hasFilters: boolean) => void;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
onFilterChange,
|
||||||
|
onToggleSidebar,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const username = useAppSelector((state) => state.auth.username);
|
||||||
|
const user = useAppSelector((state) => state.profile.user);
|
||||||
|
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||||
|
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||||
|
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
|
||||||
|
onFilterChange?.(hasFilters);
|
||||||
|
}, [selectedDate, selectedTag, searchQuery, onFilterChange]);
|
||||||
|
|
||||||
|
const loadUserInfo = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await userApi.getProfile();
|
||||||
|
dispatch(setUser(userData));
|
||||||
|
|
||||||
|
// Загружаем AI настройки
|
||||||
|
try {
|
||||||
|
const aiSettings = await userApi.getAiSettings();
|
||||||
|
dispatch(setAiSettings(aiSettings));
|
||||||
|
} catch (aiError) {
|
||||||
|
console.error("Ошибка загрузки AI настроек:", aiError);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки информации о пользователе:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
dispatch(clearAuth());
|
||||||
|
navigate("/");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка выхода:", error);
|
||||||
|
dispatch(clearAuth());
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
dispatch(setSelectedDate(null));
|
||||||
|
dispatch(setSelectedTag(null));
|
||||||
|
dispatch(setSearchQuery(""));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
|
||||||
|
|
||||||
|
// Формируем список активных фильтров
|
||||||
|
const getActiveFilters = () => {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
filters.push(`Поиск: "${searchQuery}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDate) {
|
||||||
|
filters.push(`Дата: ${selectedDate}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTag) {
|
||||||
|
filters.push(`Тег: #${selectedTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFilters = getActiveFilters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Кнопка мобильного меню */}
|
||||||
|
{onToggleSidebar && (
|
||||||
|
<button className="mobile-menu-btn" onClick={onToggleSidebar}>
|
||||||
|
<Icon icon="mdi:menu" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="notes-header">
|
||||||
|
<div className="notes-header-left">
|
||||||
|
<span>
|
||||||
|
<Icon icon="mdi:note-text" /> Мои заметки
|
||||||
|
</span>
|
||||||
|
{hasFilters && (
|
||||||
|
<div
|
||||||
|
className="filter-indicator"
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
>
|
||||||
|
Фильтр: {activeFilters.join(", ")}{" "}
|
||||||
|
<button onClick={handleClearFilters}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="user-info">
|
||||||
|
{user?.avatar ? (
|
||||||
|
<div
|
||||||
|
className="user-avatar-mini"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
title="Перейти в профиль"
|
||||||
|
onClick={() => navigate("/profile")}
|
||||||
|
>
|
||||||
|
<img src={user.avatar} alt="Аватар" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="user-avatar-mini user-avatar-placeholder-mini"
|
||||||
|
style={{ display: "flex" }}
|
||||||
|
title="Перейти в профиль"
|
||||||
|
onClick={() => navigate("/profile")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:account" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
className="settings-icon-btn"
|
||||||
|
title="Настройки"
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:cog" />
|
||||||
|
</button>
|
||||||
|
<button className="logout-btn" title="Выйти" onClick={handleLogout}>
|
||||||
|
<Icon icon="mdi:logout" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/layout/MobileSidebar.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { MiniCalendar } from "../calendar/MiniCalendar";
|
||||||
|
import { SearchBar } from "../search/SearchBar";
|
||||||
|
import { TagsFilter } from "../search/TagsFilter";
|
||||||
|
import { useAppSelector } from "../../store/hooks";
|
||||||
|
|
||||||
|
interface MobileSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileSidebar: React.FC<MobileSidebarProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const allNotes = useAppSelector((state) => state.notes.allNotes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Оверлей */}
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-overlay ${isOpen ? "open" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Сайдбар */}
|
||||||
|
<div className={`mobile-sidebar ${isOpen ? "open" : ""}`}>
|
||||||
|
<button className="sidebar-close-btn" onClick={onClose}>
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
<div className="sidebar-content">
|
||||||
|
<div className="mobile-calendar-section">
|
||||||
|
<MiniCalendar notes={allNotes} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-search-section">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-tags-section">
|
||||||
|
<TagsFilter notes={allNotes} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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 {
|
||||||
|
notes: Note[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ notes }) => {
|
||||||
|
return (
|
||||||
|
<div className="container-leftside">
|
||||||
|
<MiniCalendar notes={notes} />
|
||||||
|
<SearchBar />
|
||||||
|
<TagsFilter notes={notes} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
src/components/notes/FileUpload.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
files: File[];
|
||||||
|
onChange: (files: File[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUpload: React.FC<FileUploadProps> = ({ files, onChange }) => {
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
onChange(files.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
onChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (filename: string): string => {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
if (ext === "pdf") return "mdi:file-pdf";
|
||||||
|
if (["doc", "docx"].includes(ext)) return "mdi:file-word";
|
||||||
|
if (["xls", "xlsx"].includes(ext)) return "mdi:file-excel";
|
||||||
|
if (ext === "txt") return "mdi:file-document";
|
||||||
|
if (["zip", "rar", "7z"].includes(ext)) return "mdi:folder-zip";
|
||||||
|
return "mdi:file";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-preview-container" style={{ display: "block" }}>
|
||||||
|
<div className="file-preview-header">
|
||||||
|
<span>Прикрепленные файлы:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-files-btn"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
>
|
||||||
|
Очистить все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="file-preview-list">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={index} className="file-preview-item">
|
||||||
|
<Icon icon={getFileIcon(file.name)} className="file-icon" />
|
||||||
|
<div className="file-info">
|
||||||
|
<div className="file-name">{file.name}</div>
|
||||||
|
<div className="file-size">{formatFileSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="file-preview-remove"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
154
src/components/notes/FloatingToolbar.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface FloatingToolbarProps {
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
onFormat: (before: string, after: string) => void;
|
||||||
|
visible: boolean;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
onHide?: () => void;
|
||||||
|
onInsertColor?: () => void;
|
||||||
|
activeFormats?: {
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||||
|
textareaRef,
|
||||||
|
onFormat,
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
onHide,
|
||||||
|
onInsertColor,
|
||||||
|
activeFormats = {},
|
||||||
|
}) => {
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && toolbarRef.current) {
|
||||||
|
// Корректируем позицию, чтобы toolbar не выходил за границы экрана
|
||||||
|
const toolbar = toolbarRef.current;
|
||||||
|
const rect = toolbar.getBoundingClientRect();
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let top = position.top - toolbar.offsetHeight - 10;
|
||||||
|
let left = position.left;
|
||||||
|
|
||||||
|
// Если toolbar выходит за правую границу экрана
|
||||||
|
if (left + rect.width > windowWidth) {
|
||||||
|
left = windowWidth - rect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если toolbar выходит за левую границу экрана
|
||||||
|
if (left < 10) {
|
||||||
|
left = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если toolbar выходит за верхнюю границу экрана
|
||||||
|
if (top < 10) {
|
||||||
|
top = position.top + 30; // Показываем снизу от выделения
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.style.top = `${top}px`;
|
||||||
|
toolbar.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
}, [visible, position]);
|
||||||
|
|
||||||
|
const handleFormat = (before: string, after: string) => {
|
||||||
|
onFormat(before, after);
|
||||||
|
// Не скрываем toolbar - оставляем его видимым для дальнейших действий
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
// Обновляем выделение после применения форматирования
|
||||||
|
const start = textareaRef.current.selectionStart;
|
||||||
|
const end = textareaRef.current.selectionEnd;
|
||||||
|
if (start !== end) {
|
||||||
|
textareaRef.current.setSelectionRange(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className="floating-toolbar"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: `${position.top}px`,
|
||||||
|
left: `${position.left}px`,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Предотвращаем потерю выделения при клике на toolbar
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`}
|
||||||
|
onClick={() => handleFormat("**", "**")}
|
||||||
|
title="Жирный"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`floating-toolbar-btn ${
|
||||||
|
activeFormats.italic ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFormat("*", "*")}
|
||||||
|
title="Курсив"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-italic" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`floating-toolbar-btn ${
|
||||||
|
activeFormats.strikethrough ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFormat("~~", "~~")}
|
||||||
|
title="Зачеркнутый"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-strikethrough" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="floating-toolbar-separator" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="floating-toolbar-btn"
|
||||||
|
onClick={() => onInsertColor?.()}
|
||||||
|
title="Цвет текста"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:palette" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="floating-toolbar-btn"
|
||||||
|
onClick={() => handleFormat("||", "||")}
|
||||||
|
title="Скрытый текст"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:eye-off" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="floating-toolbar-btn"
|
||||||
|
onClick={() => handleFormat("`", "`")}
|
||||||
|
title="Код"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:code-tags" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="floating-toolbar-btn"
|
||||||
|
onClick={() => handleFormat("> ", "")}
|
||||||
|
title="Цитата"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-quote-close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
src/components/notes/ImageUpload.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface ImageUploadProps {
|
||||||
|
images: File[];
|
||||||
|
onChange: (images: File[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||||
|
images,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
onChange(images.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
onChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-preview-container" style={{ display: "block" }}>
|
||||||
|
<div className="image-preview-header">
|
||||||
|
<span>Загруженные изображения:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-images-btn"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
>
|
||||||
|
Очистить все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="image-preview-list">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div key={index} className="image-preview-item">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(image)}
|
||||||
|
alt={`Preview ${index + 1}`}
|
||||||
|
className="image-preview-thumbnail"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="image-preview-remove"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
src/components/notes/MarkdownToolbar.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppDispatch } from "../../store/hooks";
|
||||||
|
import { togglePreviewMode } from "../../store/slices/uiSlice";
|
||||||
|
|
||||||
|
interface MarkdownToolbarProps {
|
||||||
|
onInsert: (before: string, after?: string) => void;
|
||||||
|
onImageClick?: () => void;
|
||||||
|
onFileClick?: () => void;
|
||||||
|
onPreviewToggle?: () => void;
|
||||||
|
isPreviewMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
||||||
|
onInsert,
|
||||||
|
onImageClick,
|
||||||
|
onFileClick,
|
||||||
|
onPreviewToggle,
|
||||||
|
isPreviewMode,
|
||||||
|
}) => {
|
||||||
|
const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowHeaderDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showHeaderDropdown) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showHeaderDropdown]);
|
||||||
|
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="markdown-buttons">
|
||||||
|
{buttons.map((btn) => (
|
||||||
|
<button
|
||||||
|
key={btn.id}
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => {
|
||||||
|
if (btn.action) {
|
||||||
|
btn.action();
|
||||||
|
} else {
|
||||||
|
onInsert(btn.before!, btn.after);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={btn.title}
|
||||||
|
>
|
||||||
|
<Icon icon={btn.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="header-dropdown" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => setShowHeaderDropdown(!showHeaderDropdown)}
|
||||||
|
title="Заголовок"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-header-pound" />
|
||||||
|
<Icon
|
||||||
|
icon="mdi:menu-down"
|
||||||
|
style={{ fontSize: "10px", marginLeft: "-2px" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{showHeaderDropdown && (
|
||||||
|
<div className="header-dropdown-menu">
|
||||||
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
onInsert("#".repeat(level) + " ", "");
|
||||||
|
setShowHeaderDropdown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
H{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("- ", "")}
|
||||||
|
title="Список"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-list-bulleted" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("1. ", "")}
|
||||||
|
title="Нумерованный список"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-list-numbered" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("> ", "")}
|
||||||
|
title="Цитата"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-quote-close" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("`", "`")}
|
||||||
|
title="Код"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:code-tags" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("[текст ссылки](", ")")}
|
||||||
|
title="Ссылка"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:link" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onInsert("- [ ] ", "")}
|
||||||
|
title="To-Do список"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:checkbox-marked-outline" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onImageClick?.()}
|
||||||
|
title="Загрузить изображения"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:image-plus" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnMarkdown"
|
||||||
|
onClick={() => onFileClick?.()}
|
||||||
|
title="Прикрепить файлы"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:file-plus" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btnMarkdown ${isPreviewMode ? "active" : ""}`}
|
||||||
|
onClick={onPreviewToggle || (() => dispatch(togglePreviewMode()))}
|
||||||
|
title="Предпросмотр"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:monitor-eye" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
838
src/components/notes/NoteEditor.tsx
Normal file
@ -0,0 +1,838 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { MarkdownToolbar } from "./MarkdownToolbar";
|
||||||
|
import { FloatingToolbar } from "./FloatingToolbar";
|
||||||
|
import { NotePreview } from "./NotePreview";
|
||||||
|
import { ImageUpload } from "./ImageUpload";
|
||||||
|
import { FileUpload } from "./FileUpload";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { useNotification } from "../../hooks/useNotification";
|
||||||
|
import { notesApi } from "../../api/notesApi";
|
||||||
|
import { aiApi } from "../../api/aiApi";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface NoteEditorProps {
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [images, setImages] = useState<File[]>([]);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||||
|
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||||
|
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const [activeFormats, setActiveFormats] = useState({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strikethrough: false,
|
||||||
|
});
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode);
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!content.trim()) {
|
||||||
|
showNotification("Введите текст заметки", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toLocaleDateString("ru-RU");
|
||||||
|
const time = now.toLocaleTimeString("ru-RU", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await notesApi.create({ content, date, time });
|
||||||
|
|
||||||
|
// Загружаем изображения
|
||||||
|
if (images.length > 0) {
|
||||||
|
await notesApi.uploadImages(note.id, images);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем файлы
|
||||||
|
if (files.length > 0) {
|
||||||
|
await notesApi.uploadFiles(note.id, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification("Заметка сохранена!", "success");
|
||||||
|
setContent("");
|
||||||
|
setImages([]);
|
||||||
|
setFiles([]);
|
||||||
|
onSave();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка сохранения заметки:", error);
|
||||||
|
showNotification("Ошибка сохранения заметки", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiImprove = async () => {
|
||||||
|
if (!content.trim()) {
|
||||||
|
showNotification("Введите текст для улучшения", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAiLoading(true);
|
||||||
|
try {
|
||||||
|
const improvedText = await aiApi.improveText(content);
|
||||||
|
setContent(improvedText);
|
||||||
|
showNotification("Текст улучшен!", "success");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка улучшения текста:", error);
|
||||||
|
showNotification("Ошибка улучшения текста", "error");
|
||||||
|
} finally {
|
||||||
|
setIsAiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для определения активных форматов в выделенном тексте
|
||||||
|
const getActiveFormats = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return { bold: false, italic: false, strikethrough: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
// Если нет выделения, возвращаем все false
|
||||||
|
if (start === end) {
|
||||||
|
return { bold: false, italic: false, strikethrough: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
// Инициализируем форматы
|
||||||
|
const formats = {
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strikethrough: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Расширяем область проверки для захвата всех возможных тегов
|
||||||
|
const checkRange = 10; // Проверяем до 10 символов перед и после
|
||||||
|
const checkStart = Math.max(0, start - checkRange);
|
||||||
|
const checkEnd = Math.min(content.length, end + checkRange);
|
||||||
|
const contextText = content.substring(checkStart, checkEnd);
|
||||||
|
const selectionInContext = start - checkStart;
|
||||||
|
|
||||||
|
// Текст вокруг выделения
|
||||||
|
const beforeContext = contextText.substring(0, selectionInContext);
|
||||||
|
const afterContext = contextText.substring(
|
||||||
|
selectionInContext + selectedText.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Функция для подсчета символов в конце строки
|
||||||
|
const countTrailingChars = (text: string, char: string) => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = text.length - 1; i >= 0; i--) {
|
||||||
|
if (text[i] === char) count++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для подсчета символов в начале строки
|
||||||
|
const countLeadingChars = (text: string, char: string) => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === char) count++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверяем зачеркнутый (~~) - проверяем первым, так как он не пересекается с другими
|
||||||
|
const strikethroughBefore = beforeContext.slice(-2);
|
||||||
|
const strikethroughAfter = afterContext.slice(0, 2);
|
||||||
|
const hasStrikethroughAround =
|
||||||
|
strikethroughBefore === "~~" && strikethroughAfter === "~~";
|
||||||
|
const hasStrikethroughInside =
|
||||||
|
selectedText.startsWith("~~") &&
|
||||||
|
selectedText.endsWith("~~") &&
|
||||||
|
selectedText.length >= 4;
|
||||||
|
|
||||||
|
if (hasStrikethroughAround || hasStrikethroughInside) {
|
||||||
|
formats.strikethrough = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем звездочки для жирного и курсива
|
||||||
|
// Подсчитываем количество звездочек в конце контекста перед выделением
|
||||||
|
const trailingStars = countTrailingChars(beforeContext, "*");
|
||||||
|
const leadingStars = countLeadingChars(afterContext, "*");
|
||||||
|
const leadingStarsInSelection = countLeadingChars(selectedText, "*");
|
||||||
|
const trailingStarsInSelection = countTrailingChars(selectedText, "*");
|
||||||
|
|
||||||
|
// Проверяем жирный (**)
|
||||||
|
// Жирный требует минимум 2 звездочки с каждой стороны
|
||||||
|
const hasBoldStarsBefore = trailingStars >= 2;
|
||||||
|
const hasBoldStarsAfter = leadingStars >= 2;
|
||||||
|
const hasBoldStarsInSelection =
|
||||||
|
leadingStarsInSelection >= 2 && trailingStarsInSelection >= 2;
|
||||||
|
|
||||||
|
// Проверяем, что это действительно жирный
|
||||||
|
if (hasBoldStarsBefore && hasBoldStarsAfter) {
|
||||||
|
formats.bold = true;
|
||||||
|
} else if (hasBoldStarsInSelection && selectedText.length >= 4) {
|
||||||
|
formats.bold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем курсив (*)
|
||||||
|
// Если есть 3+ звездочки с каждой стороны, это комбинация жирного и курсива
|
||||||
|
// Если есть 1 звездочка с каждой стороны (и не 2), это курсив
|
||||||
|
// Если есть 3+ звездочки, это жирный + курсив
|
||||||
|
const exactItalicBefore =
|
||||||
|
trailingStars === 1 || (trailingStars >= 3 && trailingStars % 2 === 1);
|
||||||
|
const exactItalicAfter =
|
||||||
|
leadingStars === 1 || (leadingStars >= 3 && leadingStars % 2 === 1);
|
||||||
|
const exactItalicInSelection =
|
||||||
|
(leadingStarsInSelection === 1 && trailingStarsInSelection === 1) ||
|
||||||
|
(leadingStarsInSelection >= 3 &&
|
||||||
|
trailingStarsInSelection >= 3 &&
|
||||||
|
leadingStarsInSelection % 2 === 1 &&
|
||||||
|
trailingStarsInSelection % 2 === 1);
|
||||||
|
|
||||||
|
// Проверяем также внутри выделения
|
||||||
|
if (exactItalicBefore && exactItalicAfter && !formats.bold) {
|
||||||
|
// Если перед и после по 1 звездочке, это курсив
|
||||||
|
formats.italic = true;
|
||||||
|
} else if (trailingStars >= 3 && leadingStars >= 3) {
|
||||||
|
// Если по 3+ звездочки, это комбинация жирного и курсива
|
||||||
|
formats.italic = true;
|
||||||
|
formats.bold = true;
|
||||||
|
} else if (exactItalicInSelection && selectedText.length >= 2) {
|
||||||
|
formats.italic = true;
|
||||||
|
} else if (
|
||||||
|
leadingStarsInSelection === 1 &&
|
||||||
|
trailingStarsInSelection === 1 &&
|
||||||
|
selectedText.length >= 2 &&
|
||||||
|
!selectedText.startsWith("**") &&
|
||||||
|
!selectedText.endsWith("**")
|
||||||
|
) {
|
||||||
|
formats.italic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если определен жирный, но не определен курсив, и есть 3+ звездочки, значит курсив тоже есть
|
||||||
|
if (
|
||||||
|
formats.bold &&
|
||||||
|
(trailingStars >= 3 ||
|
||||||
|
leadingStars >= 3 ||
|
||||||
|
leadingStarsInSelection >= 3 ||
|
||||||
|
trailingStarsInSelection >= 3)
|
||||||
|
) {
|
||||||
|
formats.italic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formats;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const insertMarkdown = useCallback(
|
||||||
|
(before: string, after: string = "") => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = content.substring(start, end);
|
||||||
|
|
||||||
|
const tagLength = before.length;
|
||||||
|
|
||||||
|
// Проверяем область вокруг выделения (расширяем для проверки тегов)
|
||||||
|
const checkStart = Math.max(0, start - tagLength);
|
||||||
|
const checkEnd = Math.min(content.length, end + tagLength);
|
||||||
|
const contextText = content.substring(checkStart, checkEnd);
|
||||||
|
const selectionInContext = start - checkStart;
|
||||||
|
|
||||||
|
// Текст перед и после выделения в контексте
|
||||||
|
const beforeContext = contextText.substring(0, selectionInContext);
|
||||||
|
const afterContext = contextText.substring(
|
||||||
|
selectionInContext + selectedText.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверяем точное совпадение тегов
|
||||||
|
const hasTagsBefore = beforeContext.endsWith(before);
|
||||||
|
const hasTagsAfter = afterContext.startsWith(after);
|
||||||
|
|
||||||
|
// Проверяем, есть ли теги внутри выделенного текста
|
||||||
|
const startsWithTag = selectedText.startsWith(before);
|
||||||
|
const endsWithTag = selectedText.endsWith(after);
|
||||||
|
|
||||||
|
// Определяем, нужно ли удалять теги
|
||||||
|
let shouldRemoveTags = false;
|
||||||
|
|
||||||
|
if (before === "*" && after === "*") {
|
||||||
|
// Для курсива нужно убедиться, что это не часть ** (жирного)
|
||||||
|
const beforeBeforeChar = start > 1 ? content[start - 2] : "";
|
||||||
|
const afterAfterChar = end + 1 < content.length ? content[end + 1] : "";
|
||||||
|
|
||||||
|
// Проверяем, что перед * не стоит еще один *, и после * не стоит еще один *
|
||||||
|
const isItalicBefore = hasTagsBefore && beforeBeforeChar !== "*";
|
||||||
|
const isItalicAfter = hasTagsAfter && afterAfterChar !== "*";
|
||||||
|
|
||||||
|
// Проверяем внутри выделения
|
||||||
|
const hasItalicInside =
|
||||||
|
startsWithTag &&
|
||||||
|
endsWithTag &&
|
||||||
|
selectedText.length >= 2 &&
|
||||||
|
!selectedText.startsWith("**") &&
|
||||||
|
!selectedText.endsWith("**");
|
||||||
|
|
||||||
|
shouldRemoveTags = (isItalicBefore && isItalicAfter) || hasItalicInside;
|
||||||
|
} else if (before === "**" && after === "**") {
|
||||||
|
// Для жирного проверяем точное совпадение **
|
||||||
|
shouldRemoveTags =
|
||||||
|
(hasTagsBefore && hasTagsAfter) ||
|
||||||
|
(startsWithTag && endsWithTag && selectedText.length >= 4);
|
||||||
|
} else if (before === "~~" && after === "~~") {
|
||||||
|
// Для зачеркнутого проверяем точное совпадение ~~
|
||||||
|
shouldRemoveTags =
|
||||||
|
(hasTagsBefore && hasTagsAfter) ||
|
||||||
|
(startsWithTag && endsWithTag && selectedText.length >= 4);
|
||||||
|
} else {
|
||||||
|
// Для других тегов (например, ||)
|
||||||
|
shouldRemoveTags =
|
||||||
|
(hasTagsBefore && hasTagsAfter) ||
|
||||||
|
(startsWithTag &&
|
||||||
|
endsWithTag &&
|
||||||
|
selectedText.length >= tagLength * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newText: string;
|
||||||
|
let newStart: number;
|
||||||
|
let newEnd: number;
|
||||||
|
|
||||||
|
if (shouldRemoveTags) {
|
||||||
|
// Удаляем существующие теги
|
||||||
|
if (hasTagsBefore && hasTagsAfter) {
|
||||||
|
// Теги находятся вокруг выделения (в контексте)
|
||||||
|
newText =
|
||||||
|
content.substring(0, start - tagLength) +
|
||||||
|
selectedText +
|
||||||
|
content.substring(end + tagLength);
|
||||||
|
newStart = start - tagLength;
|
||||||
|
newEnd = end - tagLength;
|
||||||
|
} else {
|
||||||
|
// Теги находятся внутри выделенного текста
|
||||||
|
const innerText = selectedText.substring(
|
||||||
|
tagLength,
|
||||||
|
selectedText.length - tagLength
|
||||||
|
);
|
||||||
|
newText =
|
||||||
|
content.substring(0, start) + innerText + content.substring(end);
|
||||||
|
newStart = start;
|
||||||
|
newEnd = start + innerText.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Добавляем теги
|
||||||
|
newText =
|
||||||
|
content.substring(0, start) +
|
||||||
|
before +
|
||||||
|
selectedText +
|
||||||
|
after +
|
||||||
|
content.substring(end);
|
||||||
|
newStart = start + before.length;
|
||||||
|
newEnd = end + before.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(newText);
|
||||||
|
|
||||||
|
// Восстанавливаем фокус и выделение, затем обновляем активные форматы
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newStart, newEnd);
|
||||||
|
// Обновляем активные форматы после применения форматирования
|
||||||
|
const formats = getActiveFormats();
|
||||||
|
setActiveFormats(formats);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[content, getActiveFormats]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertColorMarkdown = useCallback(() => {
|
||||||
|
const colorDialog = document.createElement("input");
|
||||||
|
colorDialog.type = "color";
|
||||||
|
colorDialog.style.display = "none";
|
||||||
|
document.body.appendChild(colorDialog);
|
||||||
|
|
||||||
|
colorDialog.addEventListener("change", function () {
|
||||||
|
const selectedColor = this.value;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selected = content.substring(start, end);
|
||||||
|
const before = content.substring(0, start);
|
||||||
|
const after = content.substring(end);
|
||||||
|
|
||||||
|
let replacement;
|
||||||
|
if (selected.trim() === "") {
|
||||||
|
replacement = `<span style="color: ${selectedColor}">Текст</span>`;
|
||||||
|
} else {
|
||||||
|
replacement = `<span style="color: ${selectedColor}">${selected}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = before + replacement + after;
|
||||||
|
setContent(newText);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const cursorPosition = start + replacement.length;
|
||||||
|
textarea.setSelectionRange(cursorPosition, cursorPosition);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
document.body.removeChild(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
colorDialog.addEventListener("cancel", function () {
|
||||||
|
document.body.removeChild(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
colorDialog.click();
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
// Ctrl/Alt + Enter для сохранения и автопродолжение списков
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((e.altKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
// Автоматическое продолжение списков
|
||||||
|
const textarea = e.currentTarget;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
const lines = text.split("\n");
|
||||||
|
|
||||||
|
// Определяем текущую строку
|
||||||
|
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;
|
||||||
|
currentLine = lines[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentLineStart += lineLength + 1; // +1 для символа новой строки
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, является ли текущая строка списком
|
||||||
|
const listPatterns = [
|
||||||
|
/^(\s*)- \[ \] /, // Чекбокс (не отмечен): - [ ]
|
||||||
|
/^(\s*)- \[x\] /i, // Чекбокс (отмечен): - [x]
|
||||||
|
/^(\s*)- /, // Неупорядоченный список: -
|
||||||
|
/^(\s*)\* /, // Неупорядоченный список: *
|
||||||
|
/^(\s*)\+ /, // Неупорядоченный список: +
|
||||||
|
/^(\s*)(\d+)\. /, // Упорядоченный список: 1. 2. 3.
|
||||||
|
/^(\s*)(\w+)\. /, // Буквенный список: a. b. c.
|
||||||
|
/^(\s*)1\. /, // Нумерованный список (начинается с 1.)
|
||||||
|
];
|
||||||
|
|
||||||
|
let listMatch = null;
|
||||||
|
let listType = null;
|
||||||
|
|
||||||
|
for (const pattern of listPatterns) {
|
||||||
|
const match = currentLine.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
listMatch = match;
|
||||||
|
if (pattern === listPatterns[0] || pattern === listPatterns[1]) {
|
||||||
|
listType = "checkbox";
|
||||||
|
} else if (
|
||||||
|
pattern === listPatterns[2] ||
|
||||||
|
pattern === listPatterns[3] ||
|
||||||
|
pattern === listPatterns[4]
|
||||||
|
) {
|
||||||
|
listType = "unordered";
|
||||||
|
} else if (pattern === listPatterns[7]) {
|
||||||
|
// Нумерованный список всегда начинается с 1.
|
||||||
|
listType = "numbered";
|
||||||
|
} else {
|
||||||
|
listType = "ordered";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const indent = listMatch[1] || ""; // Отступы перед маркером
|
||||||
|
const marker = listMatch[0].slice(indent.length); // Маркер списка без отступов
|
||||||
|
|
||||||
|
// Получаем текст после маркера
|
||||||
|
const afterMarker = currentLine.slice(listMatch[0].length);
|
||||||
|
|
||||||
|
if (afterMarker.trim() === "") {
|
||||||
|
// Если строка пустая после маркера, выходим из списка
|
||||||
|
const beforeCursor = text.substring(0, start);
|
||||||
|
const afterCursor = text.substring(start);
|
||||||
|
|
||||||
|
// Удаляем маркер и отступы текущей строки
|
||||||
|
const newBefore = beforeCursor.replace(
|
||||||
|
/\n\s*- \[ \] \s*$|\n\s*- \[x\] \s*$|\n\s*[-*+]\s*$|\n\s*\d+\.\s*$|\n\s*\w+\.\s*$/i,
|
||||||
|
"\n"
|
||||||
|
);
|
||||||
|
const newContent = newBefore + afterCursor;
|
||||||
|
setContent(newContent);
|
||||||
|
|
||||||
|
// Устанавливаем курсор после удаленного маркера
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = newBefore.length;
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
// Продолжаем список
|
||||||
|
const beforeCursor = text.substring(0, start);
|
||||||
|
const afterCursor = text.substring(start);
|
||||||
|
|
||||||
|
let newMarker = "";
|
||||||
|
if (listType === "checkbox") {
|
||||||
|
// Для чекбоксов всегда создаем новый пустой чекбокс
|
||||||
|
newMarker = indent + "- [ ] ";
|
||||||
|
} else if (listType === "unordered") {
|
||||||
|
newMarker = indent + marker;
|
||||||
|
} else if (listType === "ordered") {
|
||||||
|
// Для упорядоченных списков увеличиваем номер
|
||||||
|
const number = parseInt(listMatch[2]);
|
||||||
|
const nextNumber = number + 1;
|
||||||
|
const numberStr = listMatch[2].replace(
|
||||||
|
/\d+/,
|
||||||
|
nextNumber.toString()
|
||||||
|
);
|
||||||
|
newMarker = indent + numberStr + ". ";
|
||||||
|
} else if (listType === "numbered") {
|
||||||
|
// Для нумерованного списка всегда начинаем с 1.
|
||||||
|
newMarker = indent + "1. ";
|
||||||
|
}
|
||||||
|
|
||||||
|
const newContent = beforeCursor + "\n" + newMarker + afterCursor;
|
||||||
|
setContent(newContent);
|
||||||
|
|
||||||
|
// Устанавливаем курсор после нового маркера
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = start + 1 + newMarker.length;
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для вычисления позиции курсора в textarea
|
||||||
|
const getCursorPosition = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return null;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
// Если нет выделения, скрываем toolbar
|
||||||
|
if (start === end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем середину выделения для позиционирования
|
||||||
|
const midPosition = Math.floor((start + end) / 2);
|
||||||
|
const text = textarea.value.substring(0, midPosition);
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const lineNumber = lines.length - 1;
|
||||||
|
const currentLineText = lines[lines.length - 1];
|
||||||
|
|
||||||
|
// Получаем размеры textarea
|
||||||
|
const rect = textarea.getBoundingClientRect();
|
||||||
|
const styles = window.getComputedStyle(textarea);
|
||||||
|
const lineHeight = parseInt(styles.lineHeight) || 20;
|
||||||
|
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||||
|
const paddingLeft = parseInt(styles.paddingLeft) || 0;
|
||||||
|
const fontSize = parseInt(styles.fontSize) || 14;
|
||||||
|
|
||||||
|
// Более точный расчет ширины символа
|
||||||
|
// Создаем временный элемент для измерения
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "pre";
|
||||||
|
measureEl.style.font = styles.font;
|
||||||
|
measureEl.textContent = currentLineText;
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
const textWidth = measureEl.offsetWidth;
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
|
||||||
|
// Вычисляем позицию (середина выделения)
|
||||||
|
const top =
|
||||||
|
rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2;
|
||||||
|
const left = rect.left + paddingLeft + textWidth;
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обработчик выделения текста
|
||||||
|
const handleSelection = useCallback(() => {
|
||||||
|
if (isPreviewMode) {
|
||||||
|
setShowFloatingToolbar(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = getCursorPosition();
|
||||||
|
if (position) {
|
||||||
|
setToolbarPosition(position);
|
||||||
|
setShowFloatingToolbar(true);
|
||||||
|
// Определяем активные форматы
|
||||||
|
const formats = getActiveFormats();
|
||||||
|
setActiveFormats(formats);
|
||||||
|
} else {
|
||||||
|
setShowFloatingToolbar(false);
|
||||||
|
setActiveFormats({ bold: false, italic: false, strikethrough: false });
|
||||||
|
}
|
||||||
|
}, [isPreviewMode, getCursorPosition, getActiveFormats]);
|
||||||
|
|
||||||
|
// Отслеживание выделения текста
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea || isPreviewMode) return;
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setTimeout(handleSelection, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (e.buttons === 1) {
|
||||||
|
// Если зажата левая кнопка мыши (выделение)
|
||||||
|
setTimeout(handleSelection, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = () => {
|
||||||
|
setTimeout(handleSelection, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener("mouseup", handleMouseUp);
|
||||||
|
textarea.addEventListener("mousemove", handleMouseMove);
|
||||||
|
textarea.addEventListener("keyup", handleKeyUp);
|
||||||
|
document.addEventListener("selectionchange", handleSelection);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
textarea.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
textarea.removeEventListener("keyup", handleKeyUp);
|
||||||
|
document.removeEventListener("selectionchange", handleSelection);
|
||||||
|
};
|
||||||
|
}, [isPreviewMode, handleSelection]);
|
||||||
|
|
||||||
|
// Скрываем toolbar при клике вне textarea и toolbar
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const target = e.target as Node;
|
||||||
|
|
||||||
|
// Проверяем, не кликнули ли на toolbar или его кнопки
|
||||||
|
const floatingToolbar = document.querySelector(".floating-toolbar");
|
||||||
|
if (floatingToolbar && floatingToolbar.contains(target)) {
|
||||||
|
// Кликнули на toolbar - не скрываем его
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea && !textarea.contains(target)) {
|
||||||
|
// Кликнули вне textarea и toolbar - скрываем только если нет выделения
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textarea.selectionStart === textarea.selectionEnd) {
|
||||||
|
setShowFloatingToolbar(false);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обновляем позицию toolbar при прокрутке
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showFloatingToolbar) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const position = getCursorPosition();
|
||||||
|
if (position) {
|
||||||
|
setToolbarPosition(position);
|
||||||
|
// Обновляем активные форматы при прокрутке
|
||||||
|
const formats = getActiveFormats();
|
||||||
|
setActiveFormats(formats);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener("scroll", handleScroll);
|
||||||
|
window.addEventListener("scroll", handleScroll, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (textarea) {
|
||||||
|
textarea.removeEventListener("scroll", handleScroll);
|
||||||
|
}
|
||||||
|
window.removeEventListener("scroll", handleScroll, true);
|
||||||
|
};
|
||||||
|
}, [showFloatingToolbar, getCursorPosition, getActiveFormats]);
|
||||||
|
|
||||||
|
// Авторасширение textarea
|
||||||
|
React.useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const autoExpand = () => {
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
textarea.style.height = textarea.scrollHeight + "px";
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener("input", autoExpand);
|
||||||
|
autoExpand();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener("input", autoExpand);
|
||||||
|
};
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleImageButtonClick = () => {
|
||||||
|
imageInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileButtonClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const newImages = files.filter(
|
||||||
|
(file) => file.type.startsWith("image/") && file.size <= 10 * 1024 * 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newImages.length + images.length > 10) {
|
||||||
|
showNotification("Можно загрузить максимум 10 изображений", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImages([...images, ...newImages]);
|
||||||
|
if (imageInputRef.current) {
|
||||||
|
imageInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files || []);
|
||||||
|
const allowedExtensions = /pdf|doc|docx|xls|xlsx|txt|zip|rar|7z/;
|
||||||
|
const allowedMimes = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"text/plain",
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-7z-compressed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const newFiles = selectedFiles.filter(
|
||||||
|
(file) =>
|
||||||
|
(allowedMimes.includes(file.type) ||
|
||||||
|
allowedExtensions.test(
|
||||||
|
file.name.split(".").pop()?.toLowerCase() || ""
|
||||||
|
)) &&
|
||||||
|
file.size <= 50 * 1024 * 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
setFiles([...files, ...newFiles]);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main">
|
||||||
|
<MarkdownToolbar
|
||||||
|
onInsert={insertMarkdown}
|
||||||
|
onImageClick={handleImageButtonClick}
|
||||||
|
onFileClick={handleFileButtonClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
id="imageInput"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
id="fileInput"
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar,.7z"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isPreviewMode && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="textInput"
|
||||||
|
id="noteInput"
|
||||||
|
placeholder="Ваша заметка..."
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<FloatingToolbar
|
||||||
|
textareaRef={textareaRef}
|
||||||
|
onFormat={insertMarkdown}
|
||||||
|
visible={showFloatingToolbar}
|
||||||
|
position={toolbarPosition}
|
||||||
|
onHide={() => setShowFloatingToolbar(false)}
|
||||||
|
onInsertColor={insertColorMarkdown}
|
||||||
|
activeFormats={activeFormats}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPreviewMode && <NotePreview content={content} />}
|
||||||
|
|
||||||
|
<ImageUpload images={images} onChange={setImages} />
|
||||||
|
<FileUpload files={files} onChange={setFiles} />
|
||||||
|
|
||||||
|
<div className="save-button-container">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{aiEnabled && (
|
||||||
|
<button
|
||||||
|
className="btnSave btnAI"
|
||||||
|
onClick={handleAiImprove}
|
||||||
|
disabled={isAiLoading}
|
||||||
|
title="Улучшить или создать текст через ИИ"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:robot" />
|
||||||
|
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btnSave" onClick={handleSave}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="save-hint">или нажмите Alt + Enter</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1313
src/components/notes/NoteItem.tsx
Normal file
25
src/components/notes/NotePreview.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { parseMarkdown } from "../../utils/markdown";
|
||||||
|
import { useMarkdown } from "../../hooks/useMarkdown";
|
||||||
|
|
||||||
|
interface NotePreviewProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotePreview: React.FC<NotePreviewProps> = ({ content }) => {
|
||||||
|
useMarkdown(); // Инициализируем обработчики спойлеров и внешних ссылок
|
||||||
|
|
||||||
|
const htmlContent = parseMarkdown(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="note-preview-container" style={{ display: "block" }}>
|
||||||
|
<div className="note-preview-header">
|
||||||
|
<span>Предпросмотр:</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="note-preview-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
src/components/notes/NotesList.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { useEffect, useImperativeHandle, forwardRef } from "react";
|
||||||
|
import { NoteItem } from "./NoteItem";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { notesApi } from "../../api/notesApi";
|
||||||
|
import { setNotes, setAllNotes } from "../../store/slices/notesSlice";
|
||||||
|
import { useNotification } from "../../hooks/useNotification";
|
||||||
|
|
||||||
|
export interface NotesListRef {
|
||||||
|
reloadNotes: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||||
|
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotes();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchQuery, selectedDate, selectedTag]);
|
||||||
|
|
||||||
|
const loadNotes = async () => {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (searchQuery || selectedDate || selectedTag) {
|
||||||
|
data = await notesApi.search({
|
||||||
|
q: searchQuery || undefined,
|
||||||
|
date: selectedDate || undefined,
|
||||||
|
tag: selectedTag || undefined,
|
||||||
|
});
|
||||||
|
// Дополнительная проверка на клиенте - фильтруем по user_id на случай проблем на сервере
|
||||||
|
if (userId) {
|
||||||
|
data = data.filter((note) => note.user_id === userId);
|
||||||
|
}
|
||||||
|
dispatch(setNotes(data));
|
||||||
|
// Обновляем также все заметки для тегов и календаря
|
||||||
|
const allData = await notesApi.getAll();
|
||||||
|
if (userId) {
|
||||||
|
const filteredAllData = allData.filter(
|
||||||
|
(note) => note.user_id === userId
|
||||||
|
);
|
||||||
|
dispatch(setAllNotes(filteredAllData));
|
||||||
|
} else {
|
||||||
|
dispatch(setAllNotes(allData));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await notesApi.getAll();
|
||||||
|
// Дополнительная проверка на клиенте
|
||||||
|
if (userId) {
|
||||||
|
data = data.filter((note) => note.user_id === userId);
|
||||||
|
}
|
||||||
|
dispatch(setNotes(data));
|
||||||
|
dispatch(setAllNotes(data)); // Сохраняем все заметки для тегов и календаря
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки заметок:", error);
|
||||||
|
showNotification("Ошибка загрузки заметок", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем все заметки при монтировании компонента для тегов и календаря
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllNotes = async () => {
|
||||||
|
try {
|
||||||
|
const data = await notesApi.getAll();
|
||||||
|
// Дополнительная проверка на клиенте
|
||||||
|
if (userId) {
|
||||||
|
const filteredData = data.filter((note) => note.user_id === userId);
|
||||||
|
dispatch(setAllNotes(filteredData));
|
||||||
|
} else {
|
||||||
|
dispatch(setAllNotes(data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки всех заметок:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (userId) {
|
||||||
|
loadAllNotes();
|
||||||
|
}
|
||||||
|
}, [dispatch, userId]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
reloadNotes: loadNotes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notesApi.delete(id);
|
||||||
|
showNotification("Заметка удалена", "success");
|
||||||
|
loadNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка удаления заметки:", error);
|
||||||
|
showNotification("Ошибка удаления заметки", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePin = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notesApi.pin(id);
|
||||||
|
loadNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка закрепления заметки:", error);
|
||||||
|
showNotification("Ошибка закрепления заметки", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notesApi.archive(id);
|
||||||
|
showNotification("Заметка архивирована", "success");
|
||||||
|
loadNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка архивирования заметки:", error);
|
||||||
|
showNotification("Ошибка архивирования заметки", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
let message = "Заметок пока нет. Создайте первую!";
|
||||||
|
if (selectedDate && selectedTag) {
|
||||||
|
message = `Нет заметок за ${selectedDate} с тегом #${selectedTag}`;
|
||||||
|
} else if (selectedDate) {
|
||||||
|
message = `Нет заметок за выбранную дату (${selectedDate})`;
|
||||||
|
} else if (selectedTag) {
|
||||||
|
message = `Нет заметок с тегом #${selectedTag}`;
|
||||||
|
} else if (searchQuery) {
|
||||||
|
message = "Ничего не найдено по запросу";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notes-container">
|
||||||
|
<p style={{ textAlign: "center", color: "#999", marginTop: "50px" }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем заметки: сначала закрепленные по времени закрепления, потом незакрепленные по дате создания (новые сверху)
|
||||||
|
const sortedNotes = [...notes].sort((a, b) => {
|
||||||
|
if (a.is_pinned !== b.is_pinned) {
|
||||||
|
return b.is_pinned - a.is_pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если обе заметки закреплены, сортируем по времени закрепления
|
||||||
|
if (a.is_pinned && b.is_pinned) {
|
||||||
|
const pinnedA = a.pinned_at ? new Date(a.pinned_at).getTime() : 0;
|
||||||
|
const pinnedB = b.pinned_at ? new Date(b.pinned_at).getTime() : 0;
|
||||||
|
return pinnedB - pinnedA; // Более свежие закрепления сверху
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для незакрепленных заметок сортируем по дате создания
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notes-container">
|
||||||
|
{sortedNotes.map((note) => (
|
||||||
|
<NoteItem
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPin={handlePin}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onReload={loadNotes}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
60
src/components/search/SearchBar.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppDispatch } from "../../store/hooks";
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Debounce для поиска
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
dispatch(setSearchQuery(query));
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query, dispatch]);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setQuery("");
|
||||||
|
dispatch(setSearchQuery(""));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-section">
|
||||||
|
<div className="search-header">
|
||||||
|
<span className="search-title">
|
||||||
|
<Icon icon="mdi:magnify" /> Поиск
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="search-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Поиск по заметкам..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
className="clear-search-btn"
|
||||||
|
onClick={handleClear}
|
||||||
|
title="Очистить поиск"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
src/components/search/TagsFilter.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../store/hooks";
|
||||||
|
import { setSelectedTag } from "../../store/slices/notesSlice";
|
||||||
|
import { extractTags } from "../../utils/markdown";
|
||||||
|
import { Note } from "../../types/note";
|
||||||
|
|
||||||
|
interface TagsFilterProps {
|
||||||
|
notes?: Note[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsFilter: React.FC<TagsFilterProps> = ({ notes = [] }) => {
|
||||||
|
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Получаем все уникальные теги из заметок
|
||||||
|
const getAllTags = () => {
|
||||||
|
const tagCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
notes.forEach((note) => {
|
||||||
|
const tags = extractTags(note.content);
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return tagCounts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagCounts = getAllTags();
|
||||||
|
const sortedTags = Object.keys(tagCounts).sort();
|
||||||
|
|
||||||
|
const handleTagClick = (tag: string) => {
|
||||||
|
if (selectedTag === tag) {
|
||||||
|
dispatch(setSelectedTag(null));
|
||||||
|
} else {
|
||||||
|
dispatch(setSelectedTag(tag));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sortedTags.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="tags-section">
|
||||||
|
<div className="tags-header">
|
||||||
|
<span className="tags-title">
|
||||||
|
<Icon icon="mdi:tag" /> Теги
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tags-container">
|
||||||
|
<div style={{ fontSize: "10px", color: "#999", textAlign: "center" }}>
|
||||||
|
Нет тегов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tags-section">
|
||||||
|
<div className="tags-header">
|
||||||
|
<span className="tags-title">
|
||||||
|
<Icon icon="mdi:tag" /> Теги
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tags-container">
|
||||||
|
{sortedTags.map((tag) => {
|
||||||
|
const count = tagCounts[tag];
|
||||||
|
const isActive = selectedTag === tag;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`tag ${isActive ? "active" : ""}`}
|
||||||
|
data-tag={tag}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
<span className="tag-count">{count}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
158
src/hooks/useMarkdown.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { notesApi } from "../api/notesApi";
|
||||||
|
|
||||||
|
interface UseMarkdownOptions {
|
||||||
|
onNoteUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMarkdown = (options?: UseMarkdownOptions) => {
|
||||||
|
const initializeHandlers = useCallback(() => {
|
||||||
|
// Обработчики спойлеров
|
||||||
|
const spoilers = document.querySelectorAll(".spoiler");
|
||||||
|
spoilers.forEach((spoiler) => {
|
||||||
|
if (!(spoiler as any)._clickHandler) {
|
||||||
|
const handler = function (this: HTMLElement, event: Event) {
|
||||||
|
if (this.classList.contains("revealed")) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
this.classList.add("revealed");
|
||||||
|
};
|
||||||
|
(spoiler as any)._clickHandler = handler;
|
||||||
|
spoiler.addEventListener("click", handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчики внешних ссылок
|
||||||
|
const externalLinks = document.querySelectorAll(".external-link");
|
||||||
|
externalLinks.forEach((link) => {
|
||||||
|
if (!(link as any)._externalClickHandler) {
|
||||||
|
const handler = function (this: HTMLAnchorElement, event: Event) {
|
||||||
|
if (
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
(window.navigator as any).standalone === true
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.open(this.href, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(link as any)._externalClickHandler = handler;
|
||||||
|
link.addEventListener("click", handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчики чекбоксов
|
||||||
|
const checkboxes = document.querySelectorAll(
|
||||||
|
".note-preview-content input[type='checkbox'], .textNote input[type='checkbox']"
|
||||||
|
);
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
|
if (!(checkbox as any)._checkboxHandler) {
|
||||||
|
const handler = async function (this: HTMLInputElement) {
|
||||||
|
// В предпросмотре просто позволяем переключаться без сохранения
|
||||||
|
if (this.closest(".note-preview-content")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для заметок - сохраняем состояние
|
||||||
|
const noteElement = this.closest("[data-note-id]") as HTMLElement;
|
||||||
|
if (!noteElement) return;
|
||||||
|
|
||||||
|
const noteId = parseInt(
|
||||||
|
noteElement.getAttribute("data-note-id") || "0"
|
||||||
|
);
|
||||||
|
if (!noteId) return;
|
||||||
|
|
||||||
|
const textNoteElement = noteElement.querySelector(
|
||||||
|
".textNote"
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!textNoteElement) return;
|
||||||
|
|
||||||
|
const originalContent = textNoteElement.getAttribute(
|
||||||
|
"data-original-content"
|
||||||
|
);
|
||||||
|
if (!originalContent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Находим все чекбоксы в этой заметке
|
||||||
|
const allCheckboxes = Array.from(
|
||||||
|
textNoteElement.querySelectorAll("input[type='checkbox']")
|
||||||
|
);
|
||||||
|
const checkboxIndex = allCheckboxes.indexOf(this);
|
||||||
|
|
||||||
|
if (checkboxIndex === -1) return;
|
||||||
|
|
||||||
|
// Обновляем markdown контент
|
||||||
|
const lines = originalContent.split("\n");
|
||||||
|
let currentCheckboxIndex = 0;
|
||||||
|
let updatedContent = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const uncheckedMatch = line.match(/^(\s*)- \[ \] (.*)$/);
|
||||||
|
const checkedMatch = line.match(/^(\s*)- \[x\] (.*)$/i);
|
||||||
|
|
||||||
|
if (uncheckedMatch || checkedMatch) {
|
||||||
|
if (currentCheckboxIndex === checkboxIndex) {
|
||||||
|
// Это наш чекбокс - переключаем состояние
|
||||||
|
if (uncheckedMatch) {
|
||||||
|
updatedContent += `${uncheckedMatch[1]}- [x] ${uncheckedMatch[2]}\n`;
|
||||||
|
} else if (checkedMatch) {
|
||||||
|
updatedContent += `${checkedMatch[1]}- [ ] ${checkedMatch[2]}\n`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedContent += line + "\n";
|
||||||
|
}
|
||||||
|
currentCheckboxIndex++;
|
||||||
|
} else {
|
||||||
|
updatedContent += line + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убираем последний перенос строки, если его не было в оригинале
|
||||||
|
if (!originalContent.endsWith("\n")) {
|
||||||
|
updatedContent = updatedContent.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в БД с флагом skipTimestamp (не считается редактированием)
|
||||||
|
await notesApi.update(noteId, updatedContent, true);
|
||||||
|
|
||||||
|
// Обновляем атрибут с оригинальным контентом
|
||||||
|
textNoteElement.setAttribute(
|
||||||
|
"data-original-content",
|
||||||
|
updatedContent
|
||||||
|
);
|
||||||
|
|
||||||
|
// Вызываем колбэк для обновления списка заметок
|
||||||
|
if (options?.onNoteUpdate) {
|
||||||
|
options.onNoteUpdate();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка сохранения чекбокса:", error);
|
||||||
|
// Откатываем изменение визуально
|
||||||
|
this.checked = !this.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(checkbox as any)._checkboxHandler = handler;
|
||||||
|
checkbox.addEventListener("change", handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Используем MutationObserver для отслеживания изменений DOM
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
initializeHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Наблюдаем за изменениями в body
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализируем обработчики сразу
|
||||||
|
initializeHandlers();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [initializeHandlers]);
|
||||||
|
};
|
||||||
22
src/hooks/useNotification.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useAppDispatch } from "../store/hooks";
|
||||||
|
import { addNotification, removeNotification } from "../store/slices/uiSlice";
|
||||||
|
|
||||||
|
export const useNotification = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const showNotification = useCallback(
|
||||||
|
(
|
||||||
|
message: string,
|
||||||
|
type: "info" | "success" | "error" | "warning" = "info"
|
||||||
|
) => {
|
||||||
|
const id = dispatch(addNotification({ message, type })).payload.id;
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatch(removeNotification(id));
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { showNotification };
|
||||||
|
};
|
||||||
39
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||||
|
import { toggleTheme, setTheme } from "../store/slices/uiSlice";
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const theme = useAppSelector((state) => state.ui.theme);
|
||||||
|
const accentColor = useAppSelector((state) => state.ui.accentColor);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
document.documentElement.style.setProperty("--accent-color", accentColor);
|
||||||
|
|
||||||
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (themeColorMeta) {
|
||||||
|
themeColorMeta.setAttribute(
|
||||||
|
"content",
|
||||||
|
theme === "dark" ? "#1a1a1a" : "#007bff"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [theme, accentColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (!localStorage.getItem("theme")) {
|
||||||
|
dispatch(setTheme(e.matches ? "dark" : "light"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaQuery.addEventListener("change", handler);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handler);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
accentColor,
|
||||||
|
toggleTheme: () => dispatch(toggleTheme()),
|
||||||
|
};
|
||||||
|
};
|
||||||
13
src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./styles/index.css";
|
||||||
|
import "./styles/theme.css";
|
||||||
|
import "./styles/style.css";
|
||||||
|
import "./styles/style-calendar.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
155
src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useSearchParams, Link } from "react-router-dom";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||||
|
import { setAuth } from "../store/slices/authSlice";
|
||||||
|
import { authApi } from "../api/authApi";
|
||||||
|
import { useNotification } from "../hooks/useNotification";
|
||||||
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate("/notes");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем наличие ошибки в URL
|
||||||
|
if (searchParams.get("error") === "invalid_password") {
|
||||||
|
setErrorMessage("Неверный пароль!");
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setErrorMessage("Логин и пароль обязательны");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Attempting login...");
|
||||||
|
const data = await authApi.login(username, password);
|
||||||
|
console.log("Login response:", data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
const authStatus = await authApi.checkStatus();
|
||||||
|
dispatch(
|
||||||
|
setAuth({
|
||||||
|
userId: authStatus.userId!,
|
||||||
|
username: authStatus.username!,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
showNotification("Успешный вход!", "success");
|
||||||
|
navigate("/notes");
|
||||||
|
} else {
|
||||||
|
setErrorMessage(data.error || "Ошибка входа");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Login error details:", error);
|
||||||
|
console.error("Error response:", error.response);
|
||||||
|
console.error("Error message:", error.message);
|
||||||
|
|
||||||
|
let errorMsg = "Ошибка соединения с сервером";
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
// Сервер ответил, но с ошибкой
|
||||||
|
errorMsg =
|
||||||
|
error.response.data?.error || `Ошибка ${error.response.status}`;
|
||||||
|
} else if (error.request) {
|
||||||
|
// Запрос был отправлен, но ответа нет
|
||||||
|
errorMsg =
|
||||||
|
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3000";
|
||||||
|
} else {
|
||||||
|
// Ошибка при настройке запроса
|
||||||
|
errorMsg = error.message || "Ошибка соединения с сервером";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
showNotification(errorMsg, "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Icon icon="mdi:login" /> Вход в систему
|
||||||
|
</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="login-form">
|
||||||
|
<form id="loginForm" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Логин:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Введите ваш логин"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="error-message" style={{ display: "block" }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btnSave" disabled={isLoading}>
|
||||||
|
{isLoading ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-link">
|
||||||
|
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="footer">
|
||||||
|
<p>
|
||||||
|
Создатель: <span>Fovway</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
54
src/pages/NotesPage.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Header } from "../components/layout/Header";
|
||||||
|
import { Sidebar } from "../components/layout/Sidebar";
|
||||||
|
import { MobileSidebar } from "../components/layout/MobileSidebar";
|
||||||
|
import { NoteEditor } from "../components/notes/NoteEditor";
|
||||||
|
import { NotesList, NotesListRef } from "../components/notes/NotesList";
|
||||||
|
import { ImageModal } from "../components/common/ImageModal";
|
||||||
|
import { useAppSelector } from "../store/hooks";
|
||||||
|
|
||||||
|
const NotesPage: React.FC = () => {
|
||||||
|
const allNotes = useAppSelector((state) => state.notes.allNotes);
|
||||||
|
const notesListRef = useRef<NotesListRef>(null);
|
||||||
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleNoteSave = () => {
|
||||||
|
// Вызываем перезагрузку заметок после создания новой заметки
|
||||||
|
if (notesListRef.current) {
|
||||||
|
notesListRef.current.reloadNotes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMobileSidebar = () => {
|
||||||
|
setIsMobileSidebarOpen(!isMobileSidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseMobileSidebar = () => {
|
||||||
|
setIsMobileSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MobileSidebar
|
||||||
|
isOpen={isMobileSidebarOpen}
|
||||||
|
onClose={handleCloseMobileSidebar}
|
||||||
|
/>
|
||||||
|
<Sidebar notes={allNotes} />
|
||||||
|
<div className="center">
|
||||||
|
<div className="container">
|
||||||
|
<Header onToggleSidebar={handleToggleMobileSidebar} />
|
||||||
|
<NoteEditor onSave={handleNoteSave} />
|
||||||
|
</div>
|
||||||
|
<NotesList ref={notesListRef} />
|
||||||
|
<div className="footer">
|
||||||
|
<p>
|
||||||
|
Создатель: <span>Fovway</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageModal />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotesPage;
|
||||||
439
src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||||
|
import { userApi } from "../api/userApi";
|
||||||
|
import { authApi } from "../api/authApi";
|
||||||
|
import { clearAuth } from "../store/slices/authSlice";
|
||||||
|
import { setUser, setAiSettings } from "../store/slices/profileSlice";
|
||||||
|
import { useNotification } from "../hooks/useNotification";
|
||||||
|
import { Modal } from "../components/common/Modal";
|
||||||
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
|
|
||||||
|
const ProfilePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const user = useAppSelector((state) => state.profile.user);
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [hasAvatar, setHasAvatar] = useState(false);
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [deletePassword, setDeletePassword] = useState("");
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await userApi.getProfile();
|
||||||
|
dispatch(setUser(userData));
|
||||||
|
setUsername(userData.username || "");
|
||||||
|
setEmail(userData.email || "");
|
||||||
|
|
||||||
|
if (userData.avatar) {
|
||||||
|
setAvatarUrl(userData.avatar);
|
||||||
|
setHasAvatar(true);
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
setHasAvatar(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем AI настройки
|
||||||
|
try {
|
||||||
|
const aiSettings = await userApi.getAiSettings();
|
||||||
|
dispatch(setAiSettings(aiSettings));
|
||||||
|
} catch (aiError) {
|
||||||
|
console.error("Ошибка загрузки AI настроек:", aiError);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки профиля:", error);
|
||||||
|
showNotification("Ошибка загрузки данных профиля", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Проверка размера файла (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showNotification(
|
||||||
|
"Файл слишком большой. Максимальный размер: 5 МБ",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка типа файла
|
||||||
|
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif"];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
showNotification(
|
||||||
|
"Недопустимый формат файла. Используйте JPG, PNG или GIF",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await userApi.uploadAvatar(file);
|
||||||
|
setAvatarUrl(result.avatar + "?t=" + Date.now());
|
||||||
|
setHasAvatar(true);
|
||||||
|
await loadProfile();
|
||||||
|
showNotification("Аватарка успешно загружена", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка загрузки аватарки:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка загрузки аватарки",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем input
|
||||||
|
if (avatarInputRef.current) {
|
||||||
|
avatarInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAvatar = async () => {
|
||||||
|
try {
|
||||||
|
await userApi.deleteAvatar();
|
||||||
|
setAvatarUrl(null);
|
||||||
|
setHasAvatar(false);
|
||||||
|
await loadProfile();
|
||||||
|
showNotification("Аватарка успешно удалена", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка удаления аватарки:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка удаления аватарки",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = async () => {
|
||||||
|
if (!username.trim()) {
|
||||||
|
showNotification("Логин не может быть пустым", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
showNotification("Логин должен быть не менее 3 символов", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email && !isValidEmail(email)) {
|
||||||
|
showNotification("Некорректный email адрес", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userApi.updateProfile({
|
||||||
|
username: username.trim(),
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
});
|
||||||
|
await loadProfile();
|
||||||
|
showNotification("Профиль успешно обновлен", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка обновления профиля:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка обновления профиля",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!currentPassword) {
|
||||||
|
showNotification("Введите текущий пароль", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
showNotification("Введите новый пароль", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
showNotification("Новый пароль должен быть не менее 6 символов", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
showNotification("Новый пароль и подтверждение не совпадают", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userApi.updateProfile({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
showNotification("Пароль успешно изменен", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка изменения пароля:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка изменения пароля",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
if (!deletePassword.trim()) {
|
||||||
|
showNotification("Введите пароль", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await userApi.deleteAccount(deletePassword);
|
||||||
|
showNotification("Аккаунт успешно удален", "success");
|
||||||
|
dispatch(clearAuth());
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/");
|
||||||
|
}, 2000);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка удаления аккаунта:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка удаления аккаунта",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidEmail = (email: string) => {
|
||||||
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return re.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header className="notes-header">
|
||||||
|
<span>
|
||||||
|
<Icon icon="mdi:account" /> Личный кабинет
|
||||||
|
</span>
|
||||||
|
<div className="user-info">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
className="back-btn"
|
||||||
|
onClick={() => navigate("/notes")}
|
||||||
|
title="К заметкам"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:note-text" /> К заметкам
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="back-btn"
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:cog" /> Настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="profile-container">
|
||||||
|
{/* Секция аватарки */}
|
||||||
|
<div className="avatar-section">
|
||||||
|
<div className="avatar-wrapper">
|
||||||
|
{hasAvatar && avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Аватар"
|
||||||
|
className="avatar-preview"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar-placeholder">
|
||||||
|
<Icon icon="mdi:account" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="avatar-buttons">
|
||||||
|
<label htmlFor="avatarInput" className="btn-upload">
|
||||||
|
<Icon icon="mdi:upload" /> Загрузить аватар
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
id="avatarInput"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
/>
|
||||||
|
{hasAvatar && (
|
||||||
|
<button className="btn-delete" onClick={handleDeleteAvatar}>
|
||||||
|
<Icon icon="mdi:delete" /> Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="avatar-hint">
|
||||||
|
Максимальный размер: 5 МБ. Форматы: JPG, PNG, GIF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция данных профиля */}
|
||||||
|
<div className="profile-form">
|
||||||
|
<h3>Данные профиля</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Логин</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Логин"
|
||||||
|
minLength={3}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="email">Email (необязательно)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="example@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btnSave" onClick={handleUpdateProfile}>
|
||||||
|
Сохранить изменения
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<hr className="separator" />
|
||||||
|
|
||||||
|
<h3>Изменить пароль</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="currentPassword">Текущий пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="currentPassword"
|
||||||
|
placeholder="Текущий пароль"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="newPassword">Новый пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="newPassword"
|
||||||
|
placeholder="Новый пароль (минимум 6 символов)"
|
||||||
|
minLength={6}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="confirmPassword">Подтвердите новый пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
placeholder="Подтвердите новый пароль"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btnSave" onClick={handleChangePassword}>
|
||||||
|
Изменить пароль
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<hr className="separator" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:account-remove" /> Удалить аккаунт
|
||||||
|
</button>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||||
|
Удаление аккаунта - это необратимое действие. Все ваши заметки,
|
||||||
|
изображения и данные будут удалены навсегда.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения удаления аккаунта */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setDeletePassword("");
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteAccount}
|
||||||
|
title="Удаление аккаунта"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#dc3545",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: "20px" }}>
|
||||||
|
Вы действительно хотите удалить свой аккаунт? Все ваши заметки,
|
||||||
|
изображения, настройки и данные будут удалены навсегда.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: "15px" }}>
|
||||||
|
<label
|
||||||
|
htmlFor="deleteAccountPassword"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "5px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Введите пароль для подтверждения:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="deleteAccountPassword"
|
||||||
|
placeholder="Пароль от аккаунта"
|
||||||
|
className="modal-password-input"
|
||||||
|
value={deletePassword}
|
||||||
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === "Enter" && !isDeleting) {
|
||||||
|
handleDeleteAccount();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText={isDeleting ? "Удаление..." : "Удалить аккаунт"}
|
||||||
|
cancelText="Отмена"
|
||||||
|
confirmType="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
176
src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||||
|
import { setAuth } from "../store/slices/authSlice";
|
||||||
|
import { authApi } from "../api/authApi";
|
||||||
|
import { useNotification } from "../hooks/useNotification";
|
||||||
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
const RegisterPage: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate("/notes");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Клиентская валидация
|
||||||
|
if (!username.trim() || !password || !confirmPassword) {
|
||||||
|
setErrorMessage("Все поля обязательны");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
setErrorMessage("Логин должен быть не менее 3 символов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setErrorMessage("Пароль должен быть не менее 6 символов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setErrorMessage("Пароли не совпадают");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Attempting registration...");
|
||||||
|
const data = await authApi.register(username, password, confirmPassword);
|
||||||
|
console.log("Register response:", data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
const authStatus = await authApi.checkStatus();
|
||||||
|
dispatch(
|
||||||
|
setAuth({
|
||||||
|
userId: authStatus.userId!,
|
||||||
|
username: authStatus.username!,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
showNotification("Регистрация успешна!", "success");
|
||||||
|
navigate("/notes");
|
||||||
|
} else {
|
||||||
|
setErrorMessage(data.error || "Ошибка регистрации");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Register error details:", error);
|
||||||
|
console.error("Error response:", error.response);
|
||||||
|
console.error("Error message:", error.message);
|
||||||
|
|
||||||
|
let errorMsg = "Ошибка соединения с сервером";
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
// Сервер ответил, но с ошибкой
|
||||||
|
errorMsg =
|
||||||
|
error.response.data?.error || `Ошибка ${error.response.status}`;
|
||||||
|
} else if (error.request) {
|
||||||
|
// Запрос был отправлен, но ответа нет
|
||||||
|
errorMsg =
|
||||||
|
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3000";
|
||||||
|
} else {
|
||||||
|
// Ошибка при настройке запроса
|
||||||
|
errorMsg = error.message || "Ошибка соединения с сервером";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
showNotification(errorMsg, "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Icon icon="mdi:account-plus" /> Регистрация
|
||||||
|
</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="login-form">
|
||||||
|
<form id="registerForm" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Логин:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Введите ваш логин (мин. 3 символа)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Введите пароль (мин. 6 символов)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="confirmPassword">Подтвердите пароль:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Подтвердите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="error-message" style={{ display: "block" }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btnSave" disabled={isLoading}>
|
||||||
|
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-link">
|
||||||
|
Уже есть аккаунт? <Link to="/">Войдите</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="footer">
|
||||||
|
<p>
|
||||||
|
Создатель: <span>Fovway</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
772
src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,772 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||||
|
import { userApi } from "../api/userApi";
|
||||||
|
import { notesApi, logsApi, Log } from "../api/notesApi";
|
||||||
|
import { Note } from "../types/note";
|
||||||
|
import { setUser, setAiSettings } from "../store/slices/profileSlice";
|
||||||
|
import { setAccentColor } from "../store/slices/uiSlice";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type SettingsTab = "appearance" | "ai" | "archive" | "logs";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>("appearance");
|
||||||
|
|
||||||
|
// Appearance settings
|
||||||
|
const [selectedAccentColor, setSelectedAccentColor] = useState("#007bff");
|
||||||
|
|
||||||
|
// AI settings
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
|
|
||||||
|
// Archive
|
||||||
|
const [archivedNotes, setArchivedNotes] = useState<Note[]>([]);
|
||||||
|
const [isLoadingArchived, setIsLoadingArchived] = useState(false);
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
const [logs, setLogs] = useState<Log[]>([]);
|
||||||
|
const [logsOffset, setLogsOffset] = useState(0);
|
||||||
|
const [hasMoreLogs, setHasMoreLogs] = useState(true);
|
||||||
|
const [logTypeFilter, setLogTypeFilter] = useState("");
|
||||||
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
|
|
||||||
|
// Delete all archived modal
|
||||||
|
const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false);
|
||||||
|
const [deleteAllPassword, setDeleteAllPassword] = useState("");
|
||||||
|
const [isDeletingAll, setIsDeletingAll] = useState(false);
|
||||||
|
|
||||||
|
const logsLimit = 50;
|
||||||
|
const colorOptions = [
|
||||||
|
{ color: "#007bff", title: "Синий" },
|
||||||
|
{ color: "#28a745", title: "Зеленый" },
|
||||||
|
{ color: "#dc3545", title: "Красный" },
|
||||||
|
{ color: "#fd7e14", title: "Оранжевый" },
|
||||||
|
{ color: "#6f42c1", title: "Фиолетовый" },
|
||||||
|
{ color: "#e83e8c", title: "Розовый" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "archive") {
|
||||||
|
loadArchivedNotes();
|
||||||
|
} else if (activeTab === "logs") {
|
||||||
|
loadLogs(true);
|
||||||
|
} else if (activeTab === "ai") {
|
||||||
|
loadAiSettings();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const loadUserInfo = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await userApi.getProfile();
|
||||||
|
dispatch(setUser(userData));
|
||||||
|
const accent = userData.accent_color || "#007bff";
|
||||||
|
setSelectedAccentColor(accent);
|
||||||
|
dispatch(setAccentColor(accent));
|
||||||
|
document.documentElement.style.setProperty("--accent-color", accent);
|
||||||
|
|
||||||
|
// Загружаем AI настройки
|
||||||
|
try {
|
||||||
|
const aiSettings = await userApi.getAiSettings();
|
||||||
|
dispatch(setAiSettings(aiSettings));
|
||||||
|
} catch (aiError) {
|
||||||
|
console.error("Ошибка загрузки AI настроек:", aiError);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки информации о пользователе:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAiSettings = async () => {
|
||||||
|
try {
|
||||||
|
const settings = await userApi.getAiSettings();
|
||||||
|
setApiKey(settings.openai_api_key || "");
|
||||||
|
setBaseUrl(settings.openai_base_url || "");
|
||||||
|
setModel(settings.openai_model || "");
|
||||||
|
setAiEnabled(settings.ai_enabled === 1);
|
||||||
|
localStorage.setItem("ai_enabled", settings.ai_enabled ? "1" : "0");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки AI настроек:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateAppearance = async () => {
|
||||||
|
try {
|
||||||
|
await userApi.updateProfile({
|
||||||
|
accent_color: selectedAccentColor,
|
||||||
|
});
|
||||||
|
dispatch(setAccentColor(selectedAccentColor));
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--accent-color",
|
||||||
|
selectedAccentColor
|
||||||
|
);
|
||||||
|
await loadUserInfo();
|
||||||
|
showNotification("Цветовой акцент успешно обновлен", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка обновления цветового акцента:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка обновления",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateAiSettings = async () => {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
showNotification("API ключ обязателен", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseUrl.trim()) {
|
||||||
|
showNotification("Base URL обязателен", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.trim()) {
|
||||||
|
showNotification("Название модели обязательно", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userApi.updateAiSettings({
|
||||||
|
openai_api_key: apiKey,
|
||||||
|
openai_base_url: baseUrl,
|
||||||
|
openai_model: model,
|
||||||
|
});
|
||||||
|
showNotification("AI настройки успешно сохранены", "success");
|
||||||
|
updateAiToggleState();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка сохранения AI настроек:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка сохранения",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiToggleChange = async (checked: boolean) => {
|
||||||
|
if (checked && !checkAiSettingsFilled()) {
|
||||||
|
showNotification("Сначала заполните все AI настройки", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userApi.updateAiSettings({
|
||||||
|
ai_enabled: checked ? 1 : 0,
|
||||||
|
});
|
||||||
|
setAiEnabled(checked);
|
||||||
|
localStorage.setItem("ai_enabled", checked ? "1" : "0");
|
||||||
|
showNotification(
|
||||||
|
checked ? "Помощь ИИ включена" : "Помощь ИИ отключена",
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка сохранения настройки AI:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка сохранения",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
setAiEnabled(!checked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAiSettingsFilled = () => {
|
||||||
|
return apiKey.trim() && baseUrl.trim() && model.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAiToggleState = () => {
|
||||||
|
const isFilled = checkAiSettingsFilled();
|
||||||
|
if (!isFilled) {
|
||||||
|
setAiEnabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadArchivedNotes = async () => {
|
||||||
|
setIsLoadingArchived(true);
|
||||||
|
try {
|
||||||
|
const notes = await notesApi.getArchived();
|
||||||
|
setArchivedNotes(notes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки архивных заметок:", error);
|
||||||
|
showNotification("Ошибка загрузки архивных заметок", "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingArchived(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreNote = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notesApi.unarchive(id);
|
||||||
|
await loadArchivedNotes();
|
||||||
|
showNotification("Заметка восстановлена!", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка восстановления заметки:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка восстановления",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePermanent = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notesApi.deleteArchived(id);
|
||||||
|
await loadArchivedNotes();
|
||||||
|
showNotification("Заметка удалена окончательно", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка удаления заметки:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка удаления",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAllArchived = async () => {
|
||||||
|
if (!deleteAllPassword.trim()) {
|
||||||
|
showNotification("Введите пароль", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeletingAll(true);
|
||||||
|
try {
|
||||||
|
await notesApi.deleteAllArchived(deleteAllPassword);
|
||||||
|
showNotification("Все архивные заметки удалены", "success");
|
||||||
|
setIsDeleteAllModalOpen(false);
|
||||||
|
setDeleteAllPassword("");
|
||||||
|
await loadArchivedNotes();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка удаления",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = useCallback(
|
||||||
|
async (reset = false) => {
|
||||||
|
setIsLoadingLogs(true);
|
||||||
|
try {
|
||||||
|
const offset = reset ? 0 : logsOffset;
|
||||||
|
const newLogs = await logsApi.getLogs({
|
||||||
|
action_type: logTypeFilter || undefined,
|
||||||
|
limit: logsLimit,
|
||||||
|
offset: offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
setLogs(newLogs);
|
||||||
|
setLogsOffset(newLogs.length);
|
||||||
|
} else {
|
||||||
|
setLogs((prevLogs) => [...prevLogs, ...newLogs]);
|
||||||
|
setLogsOffset((prevOffset) => prevOffset + newLogs.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMoreLogs(newLogs.length === logsLimit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки логов:", error);
|
||||||
|
showNotification("Ошибка загрузки логов", "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingLogs(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[logTypeFilter, logsLimit, showNotification, logsOffset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLogTypeFilterChange = (value: string) => {
|
||||||
|
setLogTypeFilter(value);
|
||||||
|
setLogsOffset(0);
|
||||||
|
setHasMoreLogs(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "logs") {
|
||||||
|
loadLogs(true);
|
||||||
|
}
|
||||||
|
}, [logTypeFilter, activeTab, loadLogs]);
|
||||||
|
|
||||||
|
const formatLogAction = (actionType: string) => {
|
||||||
|
const actionTypes: Record<string, string> = {
|
||||||
|
login: "Вход",
|
||||||
|
logout: "Выход",
|
||||||
|
register: "Регистрация",
|
||||||
|
note_create: "Создание заметки",
|
||||||
|
note_update: "Редактирование",
|
||||||
|
note_delete: "Удаление",
|
||||||
|
note_pin: "Закрепление",
|
||||||
|
note_archive: "Архивирование",
|
||||||
|
note_unarchive: "Восстановление",
|
||||||
|
note_delete_permanent: "Окончательное удаление",
|
||||||
|
profile_update: "Обновление профиля",
|
||||||
|
ai_improve: "Улучшение через AI",
|
||||||
|
};
|
||||||
|
return actionTypes[actionType] || actionType;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header className="notes-header">
|
||||||
|
<span>
|
||||||
|
<Icon icon="mdi:cog" /> Настройки
|
||||||
|
</span>
|
||||||
|
<div className="user-info">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
className="back-btn"
|
||||||
|
onClick={() => navigate("/notes")}
|
||||||
|
title="К заметкам"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:note-text" /> К заметкам
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="back-btn"
|
||||||
|
onClick={() => navigate("/profile")}
|
||||||
|
title="Профиль"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:account" /> Профиль
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Табы навигации */}
|
||||||
|
<div className="settings-tabs">
|
||||||
|
<button
|
||||||
|
className={`settings-tab ${
|
||||||
|
activeTab === "appearance" ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("appearance")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:palette" /> Внешний вид
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`settings-tab ${activeTab === "ai" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("ai")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:robot" /> AI настройки
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`settings-tab ${activeTab === "archive" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("archive")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:archive" /> Архив заметок
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`settings-tab ${activeTab === "logs" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("logs")}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:history" /> История действий
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент табов */}
|
||||||
|
<div className="settings-content">
|
||||||
|
{/* Внешний вид */}
|
||||||
|
{activeTab === "appearance" && (
|
||||||
|
<div className="tab-content active">
|
||||||
|
<h3>Внешний вид</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="settings-accentColor">Цветовой акцент</label>
|
||||||
|
<div className="accent-color-picker">
|
||||||
|
{colorOptions.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.color}
|
||||||
|
className={`color-option ${
|
||||||
|
selectedAccentColor === option.color ? "selected" : ""
|
||||||
|
}`}
|
||||||
|
data-color={option.color}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
title={option.title}
|
||||||
|
onClick={() => setSelectedAccentColor(option.color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="settings-accentColor"
|
||||||
|
value={selectedAccentColor}
|
||||||
|
style={{ marginTop: "10px" }}
|
||||||
|
onChange={(e) => setSelectedAccentColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btnSave" onClick={handleUpdateAppearance}>
|
||||||
|
Сохранить изменения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI настройки */}
|
||||||
|
{activeTab === "ai" && (
|
||||||
|
<div className="tab-content active">
|
||||||
|
<h3>Настройки AI</h3>
|
||||||
|
|
||||||
|
<div className="form-group ai-toggle-group">
|
||||||
|
<label
|
||||||
|
className={`ai-toggle-label ${
|
||||||
|
!checkAiSettingsFilled() ? "disabled" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="toggle-label-content">
|
||||||
|
<span className="toggle-text-main">Включить помощь ИИ</span>
|
||||||
|
<span className="toggle-text-desc">
|
||||||
|
{checkAiSettingsFilled()
|
||||||
|
? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
|
||||||
|
: "Сначала заполните API Key, Base URL и Модель ниже"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch-wrapper">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="ai-enabled-toggle"
|
||||||
|
className="toggle-checkbox"
|
||||||
|
checked={aiEnabled}
|
||||||
|
disabled={!checkAiSettingsFilled()}
|
||||||
|
onChange={(e) => handleAiToggleChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openai-api-key">OpenAI API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="openai-api-key"
|
||||||
|
placeholder="sk-..."
|
||||||
|
className="form-input"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value);
|
||||||
|
updateAiToggleState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||||||
|
Введите ваш OpenAI API ключ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openai-base-url">OpenAI Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="openai-base-url"
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
className="form-input"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value);
|
||||||
|
updateAiToggleState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||||||
|
URL для API запросов (например, https://api.openai.com/v1)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openai-model">Модель</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="openai-model"
|
||||||
|
placeholder="gpt-3.5-turbo"
|
||||||
|
className="form-input"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
updateAiToggleState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ color: "#666", fontSize: "12px", marginTop: "5px" }}>
|
||||||
|
Название модели (например, gpt-4, deepseek/deepseek-chat).
|
||||||
|
<a
|
||||||
|
href="https://openrouter.ai/models"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "var(--accent-color)" }}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Список доступных моделей
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btnSave" onClick={handleUpdateAiSettings}>
|
||||||
|
Сохранить AI настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Архив заметок */}
|
||||||
|
{activeTab === "archive" && (
|
||||||
|
<div className="tab-content active">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Архивные заметки</h3>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ fontSize: "14px", padding: "8px 16px" }}
|
||||||
|
onClick={() => setIsDeleteAllModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete-sweep" /> Удалить все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}
|
||||||
|
>
|
||||||
|
Архивированные заметки можно восстановить или удалить окончательно
|
||||||
|
</p>
|
||||||
|
<div className="archived-notes-list">
|
||||||
|
{isLoadingArchived ? (
|
||||||
|
<p style={{ textAlign: "center", color: "#999" }}>
|
||||||
|
Загрузка...
|
||||||
|
</p>
|
||||||
|
) : archivedNotes.length === 0 ? (
|
||||||
|
<p style={{ textAlign: "center", color: "#999" }}>Архив пуст</p>
|
||||||
|
) : (
|
||||||
|
archivedNotes.map((note) => {
|
||||||
|
const created = new Date(
|
||||||
|
note.created_at.replace(" ", "T") + "Z"
|
||||||
|
);
|
||||||
|
const dateStr = new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(created);
|
||||||
|
|
||||||
|
const htmlContent = parseMarkdown(note.content);
|
||||||
|
const preview =
|
||||||
|
htmlContent.substring(0, 200) +
|
||||||
|
(htmlContent.length > 200 ? "..." : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={note.id} className="archived-note-item">
|
||||||
|
<div className="archived-note-header">
|
||||||
|
<span className="archived-note-date">{dateStr}</span>
|
||||||
|
<div className="archived-note-actions">
|
||||||
|
<button
|
||||||
|
className="btn-restore"
|
||||||
|
onClick={() => handleRestoreNote(note.id)}
|
||||||
|
title="Восстановить"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:restore" /> Восстановить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-delete-permanent"
|
||||||
|
onClick={() => handleDeletePermanent(note.id)}
|
||||||
|
title="Удалить навсегда"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete-forever" /> Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="archived-note-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: preview }}
|
||||||
|
/>
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<div className="archived-note-images">
|
||||||
|
{note.images.length} изображений
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* История действий */}
|
||||||
|
{activeTab === "logs" && (
|
||||||
|
<div className="tab-content active">
|
||||||
|
<h3>История действий</h3>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<div className="logs-filters">
|
||||||
|
<select
|
||||||
|
id="logTypeFilter"
|
||||||
|
className="log-filter-select"
|
||||||
|
value={logTypeFilter}
|
||||||
|
onChange={(e) => handleLogTypeFilterChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все действия</option>
|
||||||
|
<option value="login">Вход</option>
|
||||||
|
<option value="logout">Выход</option>
|
||||||
|
<option value="register">Регистрация</option>
|
||||||
|
<option value="note_create">Создание заметки</option>
|
||||||
|
<option value="note_update">Редактирование заметки</option>
|
||||||
|
<option value="note_delete">Удаление заметки</option>
|
||||||
|
<option value="note_pin">Закрепление</option>
|
||||||
|
<option value="note_archive">Архивирование</option>
|
||||||
|
<option value="note_unarchive">Восстановление</option>
|
||||||
|
<option value="note_delete_permanent">
|
||||||
|
Окончательное удаление
|
||||||
|
</option>
|
||||||
|
<option value="profile_update">Обновление профиля</option>
|
||||||
|
<option value="ai_improve">Улучшение через AI</option>
|
||||||
|
</select>
|
||||||
|
<button className="btnSave" onClick={() => loadLogs(true)}>
|
||||||
|
<Icon icon="mdi:refresh" /> Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица логов */}
|
||||||
|
<div className="logs-table-container">
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата и время</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoadingLogs && logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} style={{ textAlign: "center" }}>
|
||||||
|
Загрузка...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
style={{ textAlign: "center", color: "#999" }}
|
||||||
|
>
|
||||||
|
Логов пока нет
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => {
|
||||||
|
const created = new Date(
|
||||||
|
log.created_at.replace(" ", "T") + "Z"
|
||||||
|
);
|
||||||
|
const dateStr = new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).format(created);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={log.id}>
|
||||||
|
<td>{dateStr}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`log-action-badge log-action-${log.action_type}`}
|
||||||
|
>
|
||||||
|
{formatLogAction(log.action_type)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{log.details || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMoreLogs && logs.length > 0 && (
|
||||||
|
<div className="load-more-container">
|
||||||
|
<button className="btnSave" onClick={() => loadLogs(false)}>
|
||||||
|
Загрузить еще
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения удаления всех архивных заметок */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteAllModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeleteAllModalOpen(false);
|
||||||
|
setDeleteAllPassword("");
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteAllArchived}
|
||||||
|
title="Подтверждение удаления"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#dc3545",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ ВНИМАНИЕ: Это действие нельзя отменить!
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: "20px" }}>
|
||||||
|
Вы действительно хотите удалить ВСЕ архивные заметки? Все заметки
|
||||||
|
и их изображения будут удалены навсегда.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: "15px" }}>
|
||||||
|
<label
|
||||||
|
htmlFor="deleteAllPassword"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "5px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Введите пароль для подтверждения:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="deleteAllPassword"
|
||||||
|
placeholder="Пароль от аккаунта"
|
||||||
|
className="modal-password-input"
|
||||||
|
value={deleteAllPassword}
|
||||||
|
onChange={(e) => setDeleteAllPassword(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === "Enter" && !isDeletingAll) {
|
||||||
|
handleDeleteAllArchived();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText={isDeletingAll ? "Удаление..." : "Удалить все"}
|
||||||
|
cancelText="Отмена"
|
||||||
|
confirmType="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
5
src/store/hooks.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||||
|
import type { RootState, AppDispatch } from "./index";
|
||||||
|
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
17
src/store/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
import authReducer from "./slices/authSlice";
|
||||||
|
import notesReducer from "./slices/notesSlice";
|
||||||
|
import uiReducer from "./slices/uiSlice";
|
||||||
|
import profileReducer from "./slices/profileSlice";
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: authReducer,
|
||||||
|
notes: notesReducer,
|
||||||
|
ui: uiReducer,
|
||||||
|
profile: profileReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
42
src/store/slices/authSlice.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
userId: number | null;
|
||||||
|
username: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
isAuthenticated: localStorage.getItem("isAuthenticated") === "true",
|
||||||
|
userId: null,
|
||||||
|
username: localStorage.getItem("username") || null,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authSlice = createSlice({
|
||||||
|
name: "auth",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setAuth: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ userId: number; username: string }>
|
||||||
|
) => {
|
||||||
|
state.isAuthenticated = true;
|
||||||
|
state.userId = action.payload.userId;
|
||||||
|
state.username = action.payload.username;
|
||||||
|
localStorage.setItem("isAuthenticated", "true");
|
||||||
|
localStorage.setItem("username", action.payload.username);
|
||||||
|
},
|
||||||
|
clearAuth: (state) => {
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
state.userId = null;
|
||||||
|
state.username = null;
|
||||||
|
localStorage.removeItem("isAuthenticated");
|
||||||
|
localStorage.removeItem("username");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setAuth, clearAuth } = authSlice.actions;
|
||||||
|
export default authSlice.reducer;
|
||||||
82
src/store/slices/notesSlice.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import { Note } from "../../types/note";
|
||||||
|
|
||||||
|
interface NotesState {
|
||||||
|
notes: Note[];
|
||||||
|
allNotes: Note[]; // Все заметки пользователя для тегов и календаря
|
||||||
|
archivedNotes: Note[];
|
||||||
|
selectedDate: string | null;
|
||||||
|
selectedTag: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
loading: boolean;
|
||||||
|
editingNoteId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: NotesState = {
|
||||||
|
notes: [],
|
||||||
|
allNotes: [],
|
||||||
|
archivedNotes: [],
|
||||||
|
selectedDate: null,
|
||||||
|
selectedTag: null,
|
||||||
|
searchQuery: "",
|
||||||
|
loading: false,
|
||||||
|
editingNoteId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const notesSlice = createSlice({
|
||||||
|
name: "notes",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setNotes: (state, action: PayloadAction<Note[]>) => {
|
||||||
|
state.notes = action.payload;
|
||||||
|
},
|
||||||
|
setAllNotes: (state, action: PayloadAction<Note[]>) => {
|
||||||
|
state.allNotes = action.payload;
|
||||||
|
},
|
||||||
|
addNote: (state, action: PayloadAction<Note>) => {
|
||||||
|
state.notes.unshift(action.payload);
|
||||||
|
state.allNotes.unshift(action.payload);
|
||||||
|
},
|
||||||
|
updateNote: (state, action: PayloadAction<Note>) => {
|
||||||
|
const index = state.notes.findIndex((n) => n.id === action.payload.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.notes[index] = action.payload;
|
||||||
|
}
|
||||||
|
const allIndex = state.allNotes.findIndex(
|
||||||
|
(n) => n.id === action.payload.id
|
||||||
|
);
|
||||||
|
if (allIndex !== -1) {
|
||||||
|
state.allNotes[allIndex] = action.payload;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteNote: (state, action: PayloadAction<number>) => {
|
||||||
|
state.notes = state.notes.filter((n) => n.id !== action.payload);
|
||||||
|
state.allNotes = state.allNotes.filter((n) => n.id !== action.payload);
|
||||||
|
},
|
||||||
|
setSelectedDate: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.selectedDate = action.payload;
|
||||||
|
},
|
||||||
|
setSelectedTag: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.selectedTag = action.payload;
|
||||||
|
},
|
||||||
|
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchQuery = action.payload;
|
||||||
|
},
|
||||||
|
setEditingNote: (state, action: PayloadAction<number | null>) => {
|
||||||
|
state.editingNoteId = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setNotes,
|
||||||
|
setAllNotes,
|
||||||
|
addNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote,
|
||||||
|
setSelectedDate,
|
||||||
|
setSelectedTag,
|
||||||
|
setSearchQuery,
|
||||||
|
setEditingNote,
|
||||||
|
} = notesSlice.actions;
|
||||||
|
export default notesSlice.reducer;
|
||||||
36
src/store/slices/profileSlice.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import { User, AiSettings } from "../../types/user";
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
user: User | null;
|
||||||
|
aiSettings: AiSettings | null;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ProfileState = {
|
||||||
|
user: null,
|
||||||
|
aiSettings: null,
|
||||||
|
aiEnabled: false,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileSlice = createSlice({
|
||||||
|
name: "profile",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setUser: (state, action: PayloadAction<User>) => {
|
||||||
|
state.user = action.payload;
|
||||||
|
},
|
||||||
|
setAiSettings: (state, action: PayloadAction<AiSettings>) => {
|
||||||
|
state.aiSettings = action.payload;
|
||||||
|
state.aiEnabled = action.payload.ai_enabled === 1;
|
||||||
|
},
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setUser, setAiSettings, setLoading } = profileSlice.actions;
|
||||||
|
export default profileSlice.reducer;
|
||||||
85
src/store/slices/uiSlice.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: "info" | "success" | "error" | "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiState {
|
||||||
|
theme: "light" | "dark";
|
||||||
|
accentColor: string;
|
||||||
|
notifications: Notification[];
|
||||||
|
isMobileSidebarOpen: boolean;
|
||||||
|
isPreviewMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialTheme = (): "light" | "dark" => {
|
||||||
|
const saved = localStorage.getItem("theme");
|
||||||
|
if (saved === "dark" || saved === "light") return saved;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: UiState = {
|
||||||
|
theme: getInitialTheme(),
|
||||||
|
accentColor: localStorage.getItem("accentColor") || "#007bff",
|
||||||
|
notifications: [],
|
||||||
|
isMobileSidebarOpen: false,
|
||||||
|
isPreviewMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: "ui",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleTheme: (state) => {
|
||||||
|
state.theme = state.theme === "light" ? "dark" : "light";
|
||||||
|
localStorage.setItem("theme", state.theme);
|
||||||
|
},
|
||||||
|
setTheme: (state, action: PayloadAction<"light" | "dark">) => {
|
||||||
|
state.theme = action.payload;
|
||||||
|
localStorage.setItem("theme", state.theme);
|
||||||
|
},
|
||||||
|
setAccentColor: (state, action: PayloadAction<string>) => {
|
||||||
|
state.accentColor = action.payload;
|
||||||
|
localStorage.setItem("accentColor", action.payload);
|
||||||
|
},
|
||||||
|
addNotification: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<Omit<Notification, "id">>
|
||||||
|
) => {
|
||||||
|
const id = `notification-${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.substr(2, 9)}`;
|
||||||
|
state.notifications.push({ ...action.payload, id });
|
||||||
|
},
|
||||||
|
removeNotification: (state, action: PayloadAction<string>) => {
|
||||||
|
state.notifications = state.notifications.filter(
|
||||||
|
(n) => n.id !== action.payload
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toggleMobileSidebar: (state) => {
|
||||||
|
state.isMobileSidebarOpen = !state.isMobileSidebarOpen;
|
||||||
|
},
|
||||||
|
closeMobileSidebar: (state) => {
|
||||||
|
state.isMobileSidebarOpen = false;
|
||||||
|
},
|
||||||
|
togglePreviewMode: (state) => {
|
||||||
|
state.isPreviewMode = !state.isPreviewMode;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleTheme,
|
||||||
|
setTheme,
|
||||||
|
setAccentColor,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
toggleMobileSidebar,
|
||||||
|
closeMobileSidebar,
|
||||||
|
togglePreviewMode,
|
||||||
|
} = uiSlice.actions;
|
||||||
|
export default uiSlice.reducer;
|
||||||
18
src/styles/index.css
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
144
src/styles/style-calendar.css
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/* Компактные стили для календаря */
|
||||||
|
.main-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-box {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 190px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
min-width: 75px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn:hover {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
padding: 1px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover {
|
||||||
|
background: #e8f4f8;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
color: #ccc;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.today {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: #0056cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.today:hover {
|
||||||
|
background: #0056cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected:hover {
|
||||||
|
background: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптация для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
4439
src/styles/style.css
Normal file
22
src/styles/theme.css
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
:root {
|
||||||
|
--accent-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-color: #ffffff;
|
||||||
|
--text-color: #333333;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-color: #1a1a1a;
|
||||||
|
--text-color: #e0e0e0;
|
||||||
|
--border-color: #333333;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
36
src/types/note.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export interface Note {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
content: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
is_pinned: 0 | 1;
|
||||||
|
is_archived: 0 | 1;
|
||||||
|
pinned_at?: string;
|
||||||
|
images: NoteImage[];
|
||||||
|
files: NoteFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteImage {
|
||||||
|
id: number;
|
||||||
|
note_id: number;
|
||||||
|
filename: string;
|
||||||
|
original_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteFile {
|
||||||
|
id: number;
|
||||||
|
note_id: number;
|
||||||
|
filename: string;
|
||||||
|
original_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
19
src/types/user.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
accent_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
authenticated: boolean;
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSettings {
|
||||||
|
openai_api_key: string;
|
||||||
|
openai_base_url: string;
|
||||||
|
openai_model: string;
|
||||||
|
ai_enabled: 0 | 1;
|
||||||
|
}
|
||||||
30
src/utils/dateFormat.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { ru } from "date-fns/locale";
|
||||||
|
|
||||||
|
export const parseSQLiteUtc = (timestamp: string): Date => {
|
||||||
|
return parseISO(timestamp.replace(" ", "T") + "Z");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatLocalDateTime = (date: Date): string => {
|
||||||
|
// Используем явное форматирование вместо формата date-fns для избежания проблем с локализацией
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
|
// Формат строго: dd.MM.yyyy HH:mm (ровно 16 символов)
|
||||||
|
const formatted = `${day}.${month}.${year} ${hours}:${minutes}`;
|
||||||
|
|
||||||
|
// Принудительно обрезаем до 16 символов на случай любых проблем
|
||||||
|
return formatted.substring(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatLocalDateOnly = (date: Date): string => {
|
||||||
|
return format(date, "dd.MM.yyyy", { locale: ru });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateFromTimestamp = (timestamp: string): string => {
|
||||||
|
const date = parseSQLiteUtc(timestamp);
|
||||||
|
return formatLocalDateOnly(date);
|
||||||
|
};
|
||||||
76
src/utils/filePaths.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты для формирования правильных путей к файлам и изображениям
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Формирует правильный URL для изображения заметки
|
||||||
|
* @param filePath - путь к файлу, возвращаемый сервером
|
||||||
|
* @param noteId - ID заметки
|
||||||
|
* @param imageId - ID изображения
|
||||||
|
* @returns Правильный URL для доступа к изображению
|
||||||
|
*/
|
||||||
|
export function getImageUrl(
|
||||||
|
filePath: string,
|
||||||
|
noteId: number,
|
||||||
|
imageId: number
|
||||||
|
): string {
|
||||||
|
// Если путь уже является полным URL (начинается с http:// или https://)
|
||||||
|
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /api, возвращаем как есть
|
||||||
|
if (filePath.startsWith("/api")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /uploads, возвращаем как есть (не добавляем /api)
|
||||||
|
if (filePath.startsWith("/uploads")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /, добавляем /api
|
||||||
|
if (filePath.startsWith("/")) {
|
||||||
|
return `/api${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем endpoint API для получения изображения
|
||||||
|
return `/api/notes/${noteId}/images/${imageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Формирует правильный URL для файла заметки
|
||||||
|
* @param filePath - путь к файлу, возвращаемый сервером
|
||||||
|
* @param noteId - ID заметки
|
||||||
|
* @param fileId - ID файла
|
||||||
|
* @returns Правильный URL для доступа к файлу
|
||||||
|
*/
|
||||||
|
export function getFileUrl(
|
||||||
|
filePath: string,
|
||||||
|
noteId: number,
|
||||||
|
fileId: number
|
||||||
|
): string {
|
||||||
|
// Если путь уже является полным URL (начинается с http:// или https://)
|
||||||
|
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /api, возвращаем как есть
|
||||||
|
if (filePath.startsWith("/api")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /uploads, возвращаем как есть (не добавляем /api)
|
||||||
|
if (filePath.startsWith("/uploads")) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь начинается с /, добавляем /api
|
||||||
|
if (filePath.startsWith("/")) {
|
||||||
|
return `/api${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем endpoint API для получения файла
|
||||||
|
return `/api/notes/${noteId}/files/${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
183
src/utils/markdown.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
// Расширение для спойлеров
|
||||||
|
const spoilerExtension = {
|
||||||
|
name: "spoiler",
|
||||||
|
level: "inline" as const,
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/\|\|/)?.index;
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const rule = /^\|\|(.*?)\|\|/;
|
||||||
|
const match = rule.exec(src);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: "spoiler",
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1].trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
return `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Кастомный renderer для внешних ссылок и чекбоксов
|
||||||
|
const renderer: any = {
|
||||||
|
link(token: any) {
|
||||||
|
const href = token.href;
|
||||||
|
const title = token.title;
|
||||||
|
const text = token.text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.href);
|
||||||
|
const isExternal = url.origin !== window.location.origin;
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return `<a href="${href}" title="${
|
||||||
|
title || ""
|
||||||
|
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
|
||||||
|
},
|
||||||
|
// Кастомный renderer для элементов списка с чекбоксами
|
||||||
|
listitem(token: any) {
|
||||||
|
const text = token.text;
|
||||||
|
const task = token.task;
|
||||||
|
const checked = token.checked;
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
const checkbox = `<input type="checkbox" ${checked ? "checked" : ""} />`;
|
||||||
|
return `<li class="task-list-item">${checkbox} ${text}</li>\n`;
|
||||||
|
}
|
||||||
|
return `<li>${text}</li>\n`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Настройка marked
|
||||||
|
marked.use({
|
||||||
|
extensions: [spoilerExtension],
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
renderer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const parseMarkdown = (text: string): string => {
|
||||||
|
return marked.parse(text) as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для извлечения тегов из текста
|
||||||
|
export const extractTags = (content: string): string[] => {
|
||||||
|
const tagRegex = /#([а-яё\w]+)/gi;
|
||||||
|
const tags: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = tagRegex.exec(content)) !== null) {
|
||||||
|
const matchIndex = match.index;
|
||||||
|
const beforeContext = content.substring(
|
||||||
|
Math.max(0, matchIndex - 100),
|
||||||
|
matchIndex
|
||||||
|
);
|
||||||
|
const afterContext = content.substring(
|
||||||
|
matchIndex + match[0].length,
|
||||||
|
Math.min(content.length, matchIndex + match[0].length + 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastOpenTag = beforeContext.lastIndexOf("<");
|
||||||
|
const lastCloseTag = beforeContext.lastIndexOf(">");
|
||||||
|
|
||||||
|
if (lastOpenTag > lastCloseTag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastQuote = Math.max(
|
||||||
|
beforeContext.lastIndexOf('"'),
|
||||||
|
beforeContext.lastIndexOf("'")
|
||||||
|
);
|
||||||
|
const lastEquals = beforeContext.lastIndexOf("=");
|
||||||
|
|
||||||
|
if (lastEquals > -1 && lastQuote > lastEquals) {
|
||||||
|
const nextQuote = Math.min(
|
||||||
|
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
|
||||||
|
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
|
||||||
|
);
|
||||||
|
if (nextQuote !== Infinity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = match[1].toLowerCase();
|
||||||
|
if (!tags.includes(tag)) {
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для преобразования тегов в кликабельные элементы
|
||||||
|
export const makeTagsClickable = (content: string): string => {
|
||||||
|
const tagRegex = /#([а-яё\w]+)/gi;
|
||||||
|
const matches: Array<{ fullMatch: string; tag: string; index: number }> = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = tagRegex.exec(content)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
fullMatch: match[0],
|
||||||
|
tag: match[1],
|
||||||
|
index: match.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = content;
|
||||||
|
for (let i = matches.length - 1; i >= 0; i--) {
|
||||||
|
const match = matches[i];
|
||||||
|
const beforeTag = result.substring(0, match.index);
|
||||||
|
const afterTag = result.substring(match.index + match.fullMatch.length);
|
||||||
|
|
||||||
|
const lastOpenTag = beforeTag.lastIndexOf("<");
|
||||||
|
const lastCloseTag = beforeTag.lastIndexOf(">");
|
||||||
|
|
||||||
|
if (lastOpenTag > lastCloseTag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeContext = beforeTag.substring(Math.max(0, match.index - 100));
|
||||||
|
const lastQuote = Math.max(
|
||||||
|
beforeContext.lastIndexOf('"'),
|
||||||
|
beforeContext.lastIndexOf("'")
|
||||||
|
);
|
||||||
|
const lastEquals = beforeContext.lastIndexOf("=");
|
||||||
|
|
||||||
|
if (lastEquals > -1 && lastQuote > lastEquals) {
|
||||||
|
const afterContext = afterTag.substring(
|
||||||
|
0,
|
||||||
|
Math.min(100, afterTag.length)
|
||||||
|
);
|
||||||
|
const nextQuote = Math.min(
|
||||||
|
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
|
||||||
|
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
|
||||||
|
);
|
||||||
|
if (nextQuote !== Infinity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacement = `<span class="tag-in-note" data-tag="${match.tag}">${match.fullMatch}</span>`;
|
||||||
|
result = beforeTag + replacement + afterTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для подсветки найденного текста
|
||||||
|
export const highlightSearchText = (content: string, query: string): string => {
|
||||||
|
if (!query.trim()) return content;
|
||||||
|
|
||||||
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||||
|
return content.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||||
|
};
|
||||||
15
start.sh
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для запуска NoteJS React приложения
|
||||||
|
|
||||||
|
echo "🚀 Запуск NoteJS React..."
|
||||||
|
echo ""
|
||||||
|
echo "Frontend: http://localhost:5173"
|
||||||
|
echo "Backend: http://localhost:3001"
|
||||||
|
echo ""
|
||||||
|
echo "Для остановки нажмите Ctrl+C"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Запускаем приложение
|
||||||
|
npm start
|
||||||
|
|
||||||