Переезд на React в новый репозитторий

This commit is contained in:
Fovway 2025-10-31 07:36:59 +07:00
commit 3224cffafa
103 changed files with 26966 additions and 0 deletions

50
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

44
backend/package.json Normal file
View 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"
}
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

132
backend/public/index.html Normal file
View 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
View 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

View 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
View 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));
});

View File

2665
backend/server.js Normal file

File diff suppressed because it is too large Load Diff

100
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icons/icon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/icons/icon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
public/icons/icon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

47
public/logo.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
},
};

View 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 />;
};

View 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>
);
};

View 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>
);
};

View 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}>
&times;
</span>
<img
className="image-modal-content"
id="modalImage"
src={imageSrc}
alt="Preview"
/>
</div>
);
};

View 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}>
&times;
</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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
});

View 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>
);
};

View 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
View 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]);
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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;
}

View 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

File diff suppressed because it is too large Load Diff

22
src/styles/theme.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Some files were not shown because too many files have changed in this diff Show More