Переезд на 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
|
||||
|
||||